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. Security config — define scopes and app clients
  2. Infrastructure intents — provision a Cognito User Pool
  3. Handler scopes — enforce authentication per endpoint

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',
};

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:

  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 } 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 };
});
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:

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). 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/start
export 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/verify
export 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.

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'] });