Authentication
VentureKit provides authentication through Amazon Cognito with role-based access control (RBAC) and OAuth scope enforcement.
Overview
Section titled “Overview”Authentication in VentureKit works at three levels:
- Auth intent — provision a Cognito User Pool (app clients, MFA, federation) in
vk.config.ts - Scopes — plain strings your app chooses, granted to a request from the user’s role(s)
- Handler scopes — enforce authentication per endpoint
1. Provision a User Pool
Section titled “1. Provision a User Pool”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.
2. Where scopes come from
Section titled “2. Where scopes come from”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_scopestable (structure shipped by@venturekit/auth’s migrations, rows owned by you) and resolve it with the cachedcreateRoleScopesResolver(); see Role-Based Access Control. - Multi-tenant apps — roles live per tenant in the packed
custom:tenantRolesclaim;@venturekit-pro/tenancy’screateTenantUserScopesMiddlewaregrants the matching scopes ontoctx.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:
- SPA → API:
POST /auth/federated/<provider>/start { redirectUri }. TheredirectUriis 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. - API → SPA:
{ authorizeUrl }plus an HttpOnly state cookie for CSRF protection. - SPA: navigates to
authorizeUrl. - IdP → SPA: after authentication, the IdP 302s to
redirectUri?code=…&state=…. The SPA reads the query. - SPA → API:
POST /auth/federated/<provider>/complete { code, state, redirectUri }. - API: verifies
stateagainst the cookie, exchanges the code for tokens server-side (theclient_secretnever touches the SPA), resolves the verified profile, mints a Cognito session, and returns{ user, scopes, expiresIn }plus session cookies.
For each declared provider VentureKit:
- Provisions a Secrets Manager placeholder at
venturekit/<project>/<stage>/auth/<intent.id>/<provider>holding{"clientId":"PLACEHOLDER","clientSecret":"PLACEHOLDER"}. - Exposes the secret ARN as
COGNITO_FEDERATED_<PROVIDER>_SECRET_ARNon every Lambda and grants the shared rolesecretsmanager:GetSecretValue. - Enables
ADMIN_USER_PASSWORD_AUTHon the app client and grants the Lambda role thecognito-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:
aws secretsmanager put-secret-value \ --secret-id <arn-from-cfn-output> \ --secret-string '{"clientId":"…","clientSecret":"…"}'/start route
Section titled “/start route”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 };});/complete route
Section titled “/complete route”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.
Multi-factor authentication (TOTP)
Section titled “Multi-factor authentication (TOTP)”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 payloadexport 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 appexport 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_MFAexport 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 sessionexport 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.
Sessions, cookies & sign-out
Section titled “Sessions, cookies & sign-out”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/refreshexport 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/logoutexport 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 };});Moving a session across domains (handoff)
Section titled “Moving a session across domains (handoff)”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:
| Function | Purpose |
|---|---|
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/startexport 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/verifyexport 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 (WebAuthn)
Section titled “Passkeys (WebAuthn)”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.
Customer API keys
Section titled “Customer API keys”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 hereexport 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 sessionexport 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.
Invitations
Section titled “Invitations”“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 …" screenexport const preview = handler(async (_body, ctx) => getInvitationByToken(query, ctx.queryParams?.token ?? ''));
// 3. Invitee submits name + passwordexport 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.
Impersonation (“view as user”)
Section titled “Impersonation (“view as user”)”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 grantexport 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.
3. Protect Endpoints
Section titled “3. Protect Endpoints”Add scopes to your handler to require authentication:
import { handler } from '@venturekit/runtime';
// Public — no auth requiredexport const main = handler(async (_body, ctx, logger) => { return { status: 'ok' };});
// Authenticated — requires users.read scopeexport const main = handler(async (_body, ctx, logger) => { return { userId: ctx.user?.id, email: ctx.user?.email };}, { scopes: ['users.read'] });
// Admin only — requires admin.users scopeexport const main = handler(async (_body, ctx, logger) => { return { users: [] };}, { scopes: ['admin.users'] });Role-Based Access Control
Section titled “Role-Based Access Control”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',};Checking Scopes from Roles
Section titled “Checking Scopes from Roles”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);// → trueJWT Utilities
Section titled “JWT Utilities”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 verificationconst user = extractUserFromToken(jwt); // Extract user from ID tokenconst expired = isTokenExpired(jwt); // Check exp claimconst 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',});Accessing User Context
Section titled “Accessing User Context”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'] });How user.scopes is resolved
Section titled “How user.scopes is resolved”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 membershipscustom: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'] : []), ],});