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:
- Security config — define scopes and app clients
- Infrastructure intents — provision a Cognito User Pool
- Handler scopes — enforce authentication per endpoint
1. Define Scopes
Section titled “1. Define Scopes”Scopes are defined once in config/security.ts and shared across all environments:
import type { SecurityConfig } from '@venturekit/core';
export const security: SecurityConfig = { scopes: [ { name: 'users.read', description: 'Read user data' }, { name: 'users.write', description: 'Create and update users' }, { name: 'admin.users', description: 'Admin user management' }, ], appClients: [ { name: 'web-app', allowedScopes: ['users.read', 'users.write'], supportsRefreshTokens: true, }, { name: 'admin-dashboard', allowedScopes: ['users.read', 'users.write', 'admin.users'], supportsRefreshTokens: true, generateSecret: true, }, ], mfa: 'optional',};2. Provision a User Pool
Section titled “2. Provision a User Pool”Use an auth intent to create a Cognito User Pool:
export default defineVenture({ base, security, envs: { dev, prod }, routesDir: 'src/routes', auth: [{ id: 'main', signInWith: ['email', 'phone'], allowSignUp: true, mfa: 'optional', passwordStrength: 'strong', 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.
Federated sign-in (Google / Facebook / Apple)
Section titled “Federated sign-in (Google / Facebook / Apple)”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 } from '@venturekit/runtime';import { buildAuthorizeUrl, 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. The // application's own helper builds the Set-Cookie string with the // right Path / SameSite / Max-Age — see the groupi-api example. ctx._pendingCookies = [buildOAuthStateCookie(state)]; return { authorizeUrl, state };});/complete route
Section titled “/complete route”import { handler, UnauthorizedError } from '@venturekit/runtime';import { buildSessionCookies, exchangeAuthorizationCode, signInAsFederatedUser, verifyOAuthState,} from '@venturekit/auth/server';
export const main = handler<{ code: string; state: string; redirectUri: string;}>(async (body, ctx) => { const cookieState = readEventCookie(ctx.rawEvent, 'vk_oauth_state'); 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 tokens = await signInAsFederatedUser({ profile, provider: 'google' }); ctx._pendingCookies = buildSessionCookies(tokens); return { ok: true };});signInAsFederatedUser is idempotent: on first contact it creates the
Cognito user with email_verified=true (no welcome email); on
subsequent sign-ins it re-verifies the email and mints fresh tokens.
The returned SignInResult is identical to signInWithPassword, so
the rest of your cookie / session machinery stays the same.
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.
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). Wire it to your
app’s database — Postgres, DynamoDB, anything with a key-value shape.
import { handler } from '@venturekit/runtime';import { requestVerificationCode, verifyVerificationCode, signUpUser, signInWithPassword,} from '@venturekit/auth/server';import { createVerificationCodeStore } from '../db/repos/verification-codes.js';import { notify } from '../lib/notify.js';
// /auth/sign-up/startexport const start = handler<{ channel: 'email' | 'whatsapp'; identifier: string }>( async (body, ctx) => { const store = createVerificationCodeStore(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 = createVerificationCodeStore(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 }); ctx._pendingCookies = buildSessionCookies(tokens); 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.
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'] });