Skip to content

Authentication

VentureKit provides authentication through Amazon Cognito with role-based access control (RBAC) and OAuth scope enforcement.

Authentication in VentureKit works at three levels:

  1. Auth intent — provision a Cognito User Pool (app clients, MFA, federation) in vk.config.ts
  2. Scopes — plain strings your app chooses, granted to a request from the user’s role(s)
  3. Handler scopes — enforce authentication per endpoint

Use an auth intent to create a Cognito User Pool:

export default defineVenture({
base,
envs: { dev, prod },
routesDir: 'src/routes',
auth: [{
id: 'main',
signInWith: ['email', 'phone'],
allowSignUp: true,
mfa: 'optional', // authenticator app (TOTP) — see MFA below
passwordStrength: 'strong',
clients: [{ name: 'web' }, { name: 'mobile' }], // default: one client named 'default'
tokenValidity: { accessMinutes: 15, idMinutes: 15, refreshDays: 365 },
federated: ['google', 'facebook'],
}],
});

signInWith accepts 'email', 'phone', and 'username'. Listing both 'email' and 'phone' makes them alias attributes on the same user pool — a user who registered with an email can sign in with their phone (and vice versa) without a duplicate account.

Every intent is exposed to your Lambdas as AUTH_<ID>_USER_POOL_ID, AUTH_<ID>_APP_CLIENT_ID and AUTH_<ID>_REGION (AUTH_MAIN_* here); the first declared intent is also the canonical COGNITO_* set that loadAuthServerConfig() reads by default. The pool’s per-MAU pricing tier is set per environment with identity: { featurePlan: 'lite' | 'essentials' | 'plus' } — see Configuration. The full field list (including customAttributes, tenancy, customSender and external pools) is on the Auth Intent reference.

Scopes are plain identifiers (users.read, admin.users, …) — there is no registry to declare them in. At request time, ctx.user.scopes is the union of the token’s scope, cognito:groups and custom:scopes claims (see below), and you typically grant scopes from the user’s role before the handler runs:

  • Single-tenant apps — store the role → scopes matrix in the vk_role_scopes table (structure shipped by @venturekit/auth’s migrations, rows owned by you) and resolve it with the cached createRoleScopesResolver(); see Role-Based Access Control.
  • Multi-tenant apps — roles live per tenant in the packed custom:tenantRoles claim; @venturekit-pro/tenancy’s createTenantUserScopesMiddleware grants the matching scopes onto ctx.user.scopes (see the Multi-Tenancy guide).

Handlers then enforce with scopes: [...] (section 3).

Federated sign-in (Google / Facebook / Apple / LinkedIn)

Section titled “Federated sign-in (Google / Facebook / Apple / LinkedIn)”

Listing providers under federated opts the pool into VentureKit’s server-side OAuth Authorization Code flow. The Cognito Hosted UI is not enabled — your application owns both the login screen and the redirect callback page.

The flow:

  1. SPA → API: POST /auth/federated/<provider>/start { redirectUri }. The redirectUri is a page on the SPA itself (e.g. https://app.example.com/auth/google/callback) that is registered as an allowed redirect in the IdP console.
  2. API → SPA: { authorizeUrl } plus an HttpOnly state cookie for CSRF protection.
  3. SPA: navigates to authorizeUrl.
  4. IdP → SPA: after authentication, the IdP 302s to redirectUri?code=…&state=…. The SPA reads the query.
  5. SPA → API: POST /auth/federated/<provider>/complete { code, state, redirectUri }.
  6. API: verifies state against the cookie, exchanges the code for tokens server-side (the client_secret never touches the SPA), resolves the verified profile, mints a Cognito session, and returns { user, scopes, expiresIn } plus session cookies.

For each declared provider VentureKit:

  1. Provisions a Secrets Manager placeholder at venturekit/<project>/<stage>/auth/<intent.id>/<provider> holding {"clientId":"PLACEHOLDER","clientSecret":"PLACEHOLDER"}.
  2. Exposes the secret ARN as COGNITO_FEDERATED_<PROVIDER>_SECRET_ARN on every Lambda and grants the shared role secretsmanager:GetSecretValue.
  3. Enables ADMIN_USER_PASSWORD_AUTH on the app client and grants the Lambda role the cognito-idp:Admin* actions needed to create the user and mint tokens.

After the first deploy, paste the OAuth client credentials from the provider’s developer console:

Terminal window
aws secretsmanager put-secret-value \
--secret-id <arn-from-cfn-output> \
--secret-string '{"clientId":"…","clientSecret":"…"}'
import { handler, setCookie } from '@venturekit/runtime';
import {
buildAuthorizeUrl,
buildOAuthStateCookie,
generateOAuthState,
} from '@venturekit/auth/server';
export const main = handler<{ redirectUri: string }>(async (body, ctx) => {
const state = generateOAuthState();
const authorizeUrl = await buildAuthorizeUrl({
provider: 'google',
redirectUri: body.redirectUri,
state,
});
// Pin the state to the browser via an HttpOnly cookie
// (`vk_oauth_state_google`, 10-minute lifetime, SameSite=Lax).
setCookie(ctx, buildOAuthStateCookie('google', state));
return { authorizeUrl, state };
});
import { handler, setCookie, UnauthorizedError } from '@venturekit/runtime';
import {
buildSessionCookies,
exchangeAuthorizationCode,
oauthStateCookieName,
readCookieFromEvent,
signInAsFederatedUser,
verifyOAuthState,
} from '@venturekit/auth/server';
export const main = handler<{
code: string; state: string; redirectUri: string;
}>(async (body, ctx) => {
const cookieState = readCookieFromEvent(ctx.rawEvent, oauthStateCookieName('google'));
if (!verifyOAuthState(body.state, cookieState ?? undefined)) {
throw new UnauthorizedError('Invalid OAuth state');
}
const profile = await exchangeAuthorizationCode({
provider: 'google',
code: body.code,
redirectUri: body.redirectUri,
});
const result = await signInAsFederatedUser({ profile, provider: 'google' });
for (const cookie of buildSessionCookies(result)) setCookie(ctx, cookie);
return { ok: true, accountLinked: result.accountLinked };
});

signInAsFederatedUser is idempotent: on first contact it creates the Cognito user with email_verified=true (no welcome email) and stamps custom:federated_provider; on subsequent sign-ins it rotates the server-side password and mints fresh tokens. The returned FederatedSignInResult is a SignInResult — identical to signInWithPassword, so the rest of your cookie / session machinery stays the same — plus two flags: created, and accountLinked.

accountLinked is where a policy decision hides. When the verified e-mail already belongs to a password account, 'link' (the default onExistingPasswordAccount) converts it: the user’s own password is replaced and no longer works (they can reset it), and the flag is true so you can tell them what happened. Pass onExistingPasswordAccount: 'reject' to refuse instead — the call throws federated_account_exists (409) and the account is untouched; the SPA then asks the user to sign in with their password first. The createFederatedAuthRoutes() factory takes the same option and passes accountLinked / created to your onSignIn hook.

Sign in with Apple is supported in the type surface but requires a JWT-signed client_secret rotated every 6 months — exchangeAuthorizationCode throws federated_provider_not_configured for provider: 'apple' until you implement the Apple-specific token exchange.

Set mfa: 'optional' (ask users who enrolled a factor) or mfa: 'required' (Cognito raises MFA_SETUP at sign-in for users who have not) on the auth intent. The factor is always an authenticator app — SMS is never enabled (it texts numbers most pools don’t hold, costs SNS per code, and is defeated by SIM swaps) and email OTP is not offered yet.

Every step is in @venturekit/auth/server; each Cognito call returns a fresh session that the next call must use, because these run in Lambdas that won’t handle the next request.

A signed-in user turns MFA on:

import { handler } from '@venturekit/runtime';
import { beginTotpSetup, confirmTotpSetup, setTotpPreference, otpauthUri } from '@venturekit/auth/server';
// POST /auth/mfa/setup — returns the QR payload
export const setup = handler(async (_body, ctx) => {
const { secret } = await beginTotpSetup({ accessToken: readAccessToken(ctx) });
return { otpauth: otpauthUri({ secret, account: ctx.user!.email!, issuer: 'My App' }) };
}, { scopes: ['profile.write'] });
// POST /auth/mfa/confirm — first code from the app
export const confirm = handler<{ code: string }>(async (body, ctx) => {
const accessToken = readAccessToken(ctx);
await confirmTotpSetup({ accessToken, code: body.code, deviceName: 'Authenticator' });
await setTotpPreference({ accessToken, enabled: true }); // next sign-in asks for a code
return { ok: true };
}, { scopes: ['profile.write'] });

At sign-in, handle the challenges:

import {
signInWithPasswordOrChallenge, respondToSoftwareTokenMfa,
beginTotpSetupForChallenge, completeMfaSetupChallenge, buildSessionCookies,
} from '@venturekit/auth/server';
import { setCookie } from '@venturekit/runtime';
export const signIn = handler<{ email: string; password: string }>(async (body, ctx) => {
const result = await signInWithPasswordOrChallenge(body);
if ('challenge' in result) {
// 'SOFTWARE_TOKEN_MFA' → ask for the code; 'MFA_SETUP' → enrol first; 'NEW_PASSWORD_REQUIRED'
return { challenge: result.challenge, session: result.session, username: result.username };
}
for (const c of buildSessionCookies(result)) setCookie(ctx, c);
return { ok: true };
});
// POST /auth/mfa — answer SOFTWARE_TOKEN_MFA
export const mfa = handler<{ username: string; code: string; session: string }>(async (body, ctx) => {
const result = await respondToSoftwareTokenMfa(body);
if ('challenge' in result) return { challenge: result.challenge, session: result.session, username: result.username };
for (const c of buildSessionCookies(result)) setCookie(ctx, c);
return { ok: true };
});
// MFA_SETUP (pool requires MFA, user has no factor yet): no token exists, so enrol under the challenge session
export const mfaSetup = handler<{ session: string }>(async (body) => {
const { secret, session } = await beginTotpSetupForChallenge({ session: body.session });
return { secret, session }; // client scans, then posts the first code + this session
});
export const mfaSetupComplete = handler<{ username: string; code: string; session: string }>(async (body, ctx) => {
const tokens = await completeMfaSetupChallenge(body); // verifies the code AND finishes the sign-in
for (const c of buildSessionCookies(tokens)) setCookie(ctx, c);
return { ok: true };
});

adminGetMfaStatus({ username }) and adminSetTotpPreference({ username, enabled: false }) cover support flows such as a lost device. See the API reference for every signature.

buildSessionCookies(tokens) turns a SignInResult into three HttpOnly cookies (vk_id_token, vk_access_token, vk_refresh_token — Secure unless VENTURE_LOCAL=true); attach them with the runtime’s setCookie(ctx, …). Scoped routes authenticate them through cookieAuthMiddleware, which createHandler() injects by default, falling back to the API Gateway Cognito authorizer. For apps served on several hosts, resolveCookieDomain(getRequestHost(ctx), { … }) picks the right Domain=.

import { refreshSession, revokeRefreshToken, buildSessionCookies, buildClearSessionCookies, readCookieFromEvent, REFRESH_TOKEN_COOKIE } from '@venturekit/auth/server';
// POST /auth/refresh
export const refresh = handler(async (_body, ctx) => {
const refreshToken = readCookieFromEvent(ctx.rawEvent, REFRESH_TOKEN_COOKIE);
const tokens = await refreshSession(refreshToken!);
for (const c of buildSessionCookies({ ...tokens, refreshToken: refreshToken! })) setCookie(ctx, c);
return { ok: true };
});
// POST /auth/logout
export const logout = handler(async (_body, ctx) => {
const refreshToken = readCookieFromEvent(ctx.rawEvent, REFRESH_TOKEN_COOKIE);
if (refreshToken) await revokeRefreshToken(refreshToken).catch(() => {});
for (const c of buildClearSessionCookies()) setCookie(ctx, c);
return { ok: true };
});

Cookies cannot span registrable domains, so a white-label product (app.platform.com → tenant.io) needs an SSO-style handoff. createSessionHandoffRoutes({ store, authorize }) emits start (mints a 60-second, single-use, host-bound code after your authorize membership check) and complete (redeems it on the target host, mints first-party cookies there, 302s to next). Only the opaque code ever appears in a URL; the refresh token travels through your HandoffStore (put / atomic take). Multi-tenant apps usually pair it with @venturekit-pro/tenancy’s createTenantHandoffAuthorize.

Verification codes (email / SMS / WhatsApp OTP)

Section titled “Verification codes (email / SMS / WhatsApp OTP)”

@venturekit/auth/server ships a generic OTP toolkit for the “we’ll text you a 6-digit code” pattern that gates registration on a verified email or phone before any Cognito user exists:

FunctionPurpose
generateVerificationCode(length?)Random length-digit code (default 6, uses crypto.randomInt).
hashVerificationCode(code)SHA-256 hex digest — what the store persists.
requestVerificationCode({ channel, identifier, store, ttlSeconds?, maxAttempts? })Mint + persist + return plaintext code for delivery.
verifyVerificationCode({ channel, identifier, code, store })Constant-time compare; deletes on success, increments attempts on mismatch, wipes after maxAttempts.
createInMemoryVerificationCodeStore()Tests / vk dev only.

Storage is pluggable via the VerificationCodeStore interface (put / get / incrementAttempts / delete). The package ships a Postgres store over the vk_verification_codes table its migrations create (createPostgresVerificationCodeStore, from @venturekit/auth/server/store/postgres); any key-value shape works.

import { handler, setCookie } from '@venturekit/runtime';
import {
buildSessionCookies,
requestVerificationCode,
verifyVerificationCode,
signUpUser,
signInWithPassword,
} from '@venturekit/auth/server';
import { createPostgresVerificationCodeStore } from '@venturekit/auth/server/store/postgres';
import { notify } from '../lib/notify.js';
// /auth/sign-up/start
export const start = handler<{ channel: 'email' | 'whatsapp'; identifier: string }>(
async (body, ctx) => {
const store = createPostgresVerificationCodeStore(ctx.tx.query);
const { code } = await requestVerificationCode({
channel: body.channel,
identifier: body.identifier,
store,
ttlSeconds: 600,
});
await notify.enqueue({
channel: body.channel,
address: body.identifier,
templateKey: `auth_verification_code_${body.channel}`,
payload: { code, ttlMinutes: 10 },
});
return { ok: true };
},
);
// /auth/sign-up/verify
export const verify = handler(async (body, ctx) => {
const store = createPostgresVerificationCodeStore(ctx.tx.query);
await verifyVerificationCode({
channel: body.channel,
identifier: body.identifier,
code: body.code,
store,
});
await signUpUser({ email: body.email, password: body.password, /* … */ });
const tokens = await signInWithPassword({ email: body.email, password: body.password });
for (const cookie of buildSessionCookies(tokens)) setCookie(ctx, cookie);
return { ok: true };
});

The plaintext code is returned by requestVerificationCode so the caller can deliver it via any channel; the store only ever sees the hash. Failed attempts auto-rate-limit per code; rate-limiting the request endpoint itself (against a single identifier issuing a flood of codes) is the application’s responsibility.

Passkeys are opt-in and cost nothing extra, but Cognito only offers them on the essentials / plus feature plans (the medium / large presets) — declaring them on a lite pool fails at synth with a fix-it message rather than at CreateUserPool.

auth: [{
id: 'main',
signInWith: ['email'],
passkeys: {
relyingPartyId: 'example.com', // the apex covering every host users sign in from
userVerification: 'preferred', // 'required' insists on biometric / PIN
},
}],

relyingPartyId binds every enrolled passkey to that domain — example.com covers app.example.com and admin.example.com; changing it later invalidates all of them. The intent provisions the pool’s relying party, the choice-based USER_AUTH flow (password stays enabled) and ALLOW_USER_AUTH on every app client.

Both flows are one pair of calls around a browser round-trip. The credential the browser produces (PublicKeyCredential.toJSON()) is passed through unchanged — Cognito verifies it.

import { handler, setCookie } from '@venturekit/runtime';
import {
beginPasskeyRegistration, completePasskeyRegistration,
beginPasskeySignIn, completePasskeySignIn, buildSessionCookies,
extractToken,
} from '@venturekit/auth/server';
// Enrol — signed-in user, from a settings page
// POST /auth/passkeys/begin → options for navigator.credentials.create()
export const begin = handler(async (_body, ctx) =>
beginPasskeyRegistration(extractToken(ctx.rawEvent)!), { scopes: ['profile.write'] });
// POST /auth/passkeys/complete { credential: cred.toJSON() }
export const complete = handler<{ credential: Record<string, unknown> }>(async (body, ctx) => {
await completePasskeyRegistration(extractToken(ctx.rawEvent)!, body.credential);
return { ok: true };
}, { scopes: ['profile.write'] });
// Sign in — no password
// POST /auth/passkeys/signin/begin { email } → { session, options } for navigator.credentials.get()
export const signInBegin = handler<{ email: string }>(async (body) => beginPasskeySignIn(body.email));
// POST /auth/passkeys/signin/complete { email, session, credential }
export const signInComplete = handler<{ email: string; session: string; credential: Record<string, unknown> }>(
async (body, ctx) => {
const tokens = await completePasskeySignIn({ username: body.email, session: body.session, credential: body.credential });
for (const cookie of buildSessionCookies(tokens)) setCookie(ctx, cookie);
return { ok: true };
},
);

beginPasskeySignIn throws passkey_not_available (400) when the account has no passkey enrolled, so the SPA can fall back to the password form. completePasskeySignIn returns the same SignInResult as signInWithPassword; listPasskeys / deletePasskey back a “manage devices” screen.

For a customer’s backend calling your API (not for keys you hold for third-party APIs — that is @venturekit/integrations). A key is shown once; the vk_api_keys table keeps its SHA-256 hash and a non-secret display prefix. Keys carry their own scopes, so the route’s scopes: [...] gate does not care whether it got a JWT or a key:

import { handler } from '@venturekit/runtime';
import { apiKeyAuth, createApiKey } from '@venturekit/auth/server';
import { query } from '@venturekit/data';
// Issue — from the customer's settings page; `token` is available only here
export const issue = handler<{ name: string }>(async (body, ctx) => {
const { key, token } = await createApiKey(ctx.tx!.query, {
name: body.name,
scopes: ['orders.read', 'orders.write'],
ownerId: ctx.user!.id,
tenantId: ctx.tenant?.id,
expiresAt: new Date(Date.now() + 365 * 86_400_000),
});
return { id: key.id, prefix: key.prefix, token };
}, { scopes: ['api-keys.manage'], transactional: true });
// Accept — `X-Api-Key: vk_…` (or `Authorization: Bearer vk_…`) *or* the usual session
export const main = handler(async (_body, ctx) => listOrders(ctx.user!.id), {
scopes: ['orders.read'],
middleware: [apiKeyAuth({ querier: query })],
});

apiKeyAuth acts only when a key is presented; otherwise it passes through and the cookie / JWT middleware runs as usual, so one route serves both. It populates ctx.user with id: ownerId ?? key.id, the key’s scopes and claims.auth_method = 'api_key' (plus api_key_id, tenant_id when set, and the key’s metadata). An unknown, revoked or expired key is 401 unless onInvalid: 'ignore'. rotateApiKey issues a replacement with the same scopes and revokes the old one.

“Join as <role> in <tenant>” links that create the Cognito account when accepted. The framework owns the token lifecycle (vk_invitations); you own the e-mail that carries the link and the membership row written on acceptance.

import { handler } from '@venturekit/runtime';
import { acceptInvitation, createInvitation, getInvitationByToken } from '@venturekit/auth/server';
import { query } from '@venturekit/data';
// 1. Admin invites (inside the route's transaction)
export const invite = handler<{ email: string; role: string }>(async (body, ctx) => {
const { token } = await createInvitation(ctx.tx!.query, {
email: body.email, tenantId: ctx.tenant!.id, role: body.role, invitedBy: ctx.user!.id,
});
await notify.enqueue({ channel: 'email', address: body.email, templateKey: 'invitation',
payload: { url: `${APP_URL}/join?token=${token}` } });
return { ok: true };
}, { scopes: ['members.invite'], transactional: true });
// 2. Invitee opens the link — preview for the "you've been invited to … as …" screen
export const preview = handler(async (_body, ctx) => getInvitationByToken(query, ctx.queryParams?.token ?? ''));
// 3. Invitee submits name + password
export const join = handler<{ token: string; name: string; password: string }>(async (body, ctx) => {
const { invitation, userSub } = await acceptInvitation(ctx.tx!.query, body);
await memberships.create(ctx.tx!, { userSub, tenantId: invitation.tenantId!, role: invitation.role! });
return { ok: true };
}, { transactional: true });

Accepting is atomic and single-use: the row is claimed with a conditional UPDATE before the Cognito call, so two concurrent submits cannot both create the account, and a failed provisioning releases the claim so the invitee can retry. A dead token is invitation_invalid (410); an address that already has an account is invitation_email_taken (409) — have the invitee sign in and accept with existingUserSub, which attaches the invitation to their account instead of creating one. Re-inviting an address revokes the open invitation and issues a fresh token (onExisting: 'replace', the “resend” behaviour). Default lifetime is 7 days.

A support agent sees the product as a specific user without ever holding that user’s credentials or tokens. Minting the user’s Cognito tokens would mean rotating their password (AdminInitiateAuth needs one) and would make the agent indistinguishable from the user in every log. Instead the agent keeps their own session and adds a signed, short-lived grant — “admin A may act as user U until T”:

import { createHandler, handler } from '@venturekit/runtime';
import { cookieAuthMiddleware, impersonationMiddleware, issueImpersonationGrant, isImpersonated } from '@venturekit/auth/server';
// POST /admin/users/{id}/impersonate — the agent asks for a grant
export const main = handler(async (_body, ctx) => {
const targetSub = ctx.pathParams?.id!;
const grant = issueImpersonationGrant({ adminSub: ctx.user!.id, targetSub, ttlSeconds: 900 });
await audit.record({ action: 'impersonation.start', actor: ctx.user!.id, target: targetSub });
return { grant };
}, { scopes: ['admin.impersonate'] });
// Every route: the SPA sends the grant as `X-VK-Impersonate`
export const apiHandler = createHandler({
middleware: [
cookieAuthMiddleware(),
impersonationMiddleware({
resolveTarget: (sub) => loadUserContext(sub), // scopes + tenant claims, as a real sign-in would
denyScopes: ['admin.*', 'billing.write'], // powers the agent must never wield as the user
onImpersonate: ({ grant, ctx }) => audit.record({ action: 'impersonation.request', actor: grant.adminSub, path: ctx.path }),
}),
],
});
// A route that must refuse writes while "viewing as"
if (isImpersonated(ctx)) throw new ForbiddenError('Read-only while impersonating');

The middleware runs after authentication and before authorization / tenancy: it verifies the grant, checks the caller is the admin it was issued to, swaps ctx.user to the target with the denied scopes stripped, and sets claims.impersonated_by so every audit row and log line can tell. An invalid, expired or foreign grant is a 403 — never a silent fall-through to the admin’s own identity. Grants are HMAC-signed with VENTURE_IMPERSONATION_SECRET, falling back to the internal-call secret every deployed function already holds; they last 15 minutes by default, one hour at most.

Add scopes to your handler to require authentication:

import { handler } from '@venturekit/runtime';
// Public — no auth required
export const main = handler(async (_body, ctx, logger) => {
return { status: 'ok' };
});
// Authenticated — requires users.read scope
export const main = handler(async (_body, ctx, logger) => {
return { userId: ctx.user?.id, email: ctx.user?.email };
}, { scopes: ['users.read'] });
// Admin only — requires admin.users scope
export const main = handler(async (_body, ctx, logger) => {
return { users: [] };
}, { scopes: ['admin.users'] });

Define roles that map to sets of scopes:

import type { RolesConfig } from '@venturekit/auth';
const rolesConfig: RolesConfig = {
roles: [
{ name: 'viewer', description: 'Read only', scopes: ['users.read'] },
{ name: 'member', description: 'Standard', scopes: ['users.read', 'users.write'] },
{ name: 'admin', description: 'Full access', scopes: ['users.read', 'users.write', 'admin.users'], isSystem: true },
],
defaultRole: 'viewer',
superAdminRole: 'admin',
};
import { hasScope, getScopesForRoles, hasAllScopes } from '@venturekit/auth';
getScopesForRoles(['member'], rolesConfig);
// → ['users.read', 'users.write']
hasScope(['member'], 'admin.users', rolesConfig);
// → false
hasAllScopes(['admin'], ['users.read', 'admin.users'], rolesConfig);
// → true
import {
decodeTokenUnsafe,
verifyAndDecode,
extractUserFromToken,
isTokenExpired,
getTokenExpiry,
} from '@venturekit/auth';
// Inside a route handler protected by API Gateway's Cognito Authorizer the
// token has already been verified — the unsafe decoder is fine for reading
// claims that are already trusted.
const claims = decodeTokenUnsafe(jwt); // Decode WITHOUT verification
const user = extractUserFromToken(jwt); // Extract user from ID token
const expired = isTokenExpired(jwt); // Check exp claim
const expiry = getTokenExpiry(jwt); // Get expiry as Date
// For any path where the token has NOT already been verified upstream
// (e.g. cookie-based sessions read inside Lambda, or background jobs)
// use `verifyAndDecode` — it checks the signature against the Cognito
// JWKS and validates `exp`, `iat`, `iss`, `token_use`, and `client_id`.
const verifiedClaims = await verifyAndDecode(jwt, {
userPoolId: 'eu-west-1_xxxxx',
clientId: process.env.COGNITO_APP_CLIENT_ID,
tokenUse: 'access',
});

In authenticated handlers, ctx.user is populated automatically:

export const main = handler(async (_body, ctx, logger) => {
const user = ctx.user!;
logger.info('Request from user', {
userId: user.id,
email: user.email,
scopes: user.scopes,
});
return { profile: { id: user.id, email: user.email } };
}, { scopes: ['users.read'] });

user.scopes is built from the union of three token claims so scope gating works regardless of how Cognito is configured:

  • scope — space-delimited OAuth scopes (access tokens)
  • cognito:groups — group memberships
  • custom:scopes — a custom claim for app-specific scopes

Because these claims grant access, the pool’s app clients cannot write custom:* attributes: only server-side code holding the Cognito admin grants (signUpUser({ customAttributes }), adminUpdateUserAttributes) sets them, so a signed-in user can never promote themselves through UpdateUserAttributes.

For cookie-based sessions, cookieAuthMiddleware accepts a scopeMapper option if you need to override this default mapping (e.g. to derive scopes from a different claim):

import { cookieAuthMiddleware, defaultScopeMapper } from '@venturekit/auth/server';
cookieAuthMiddleware({
// ...
scopeMapper: (claims) => [
...defaultScopeMapper(claims),
...(claims['custom:plan'] === 'pro' ? ['billing.read'] : []),
],
});