_Note: AWS Blocks is currently in preview and the API is subject to change. _
A login form is not authentication. Federation, multi-tenancy, RBAC, MFA, audit logging, GDPR compliance, rate limiting, CSRF protection, multi-account support, and transactional email — that is authentication.
Most teams build auth incrementally. Add social login, then realize you need MFA. Add MFA, then discover you need per-tenant roles. Add roles, then panic about GDPR. Each layer is bolted on, each integration point is hand-rolled, each new requirement means rewriting the last thing you built.
AWS Blocks takes the opposite approach. The Building Blocks provide all of it, wired together from day one. One TypeScript file. Typed APIs. Infrastructure compiles from code.
This article walks through a complete enterprise auth system — the architecture, the code, and the patterns that make it work. A reference implementation accompanies this article at github.com/saddam-azad/aws-blocks-auth.
Architecture Overview
The full system in one sketch:
Browser
│
├─── email/password ──→ AuthCognito ──→ Cognito User Pool
│ │
│ session cookie (auth_*)
│ │
└─── social login ───→ AuthOIDC ─────→ Google / GitHub
│
session cookie (oidc_*_session)
│
┌─────┴─────┐
│ resolve │ ← tries Cognito first,
│ Auth │ falls back to OIDC
│ User() │
└─────┬─────┘
│
Unified API Namespace
│
┌────────────┼────────────┐
│ │ │
DistributedTable KVStore EmailClient
(orgs, users, (sessions, (SES delivery,
audit log) rate limits) React Email)
Enter fullscreen mode Exit fullscreen mode
Two separate Building Blocks, two separate session stores, one unified API layer. The frontend does not know or care which provider authenticated the user.
Concern AWS Blocks Federation AuthCognito + AuthOIDC Multi-tenancy DistributedTable +__org cookie
RBAC
Inline role checks + Pinia computed props
MFA
Cognito state machine (SMS/TOTP/Email)
Audit logging
DistributedTable with TTL
CSRF
requireSameSite() + SameSite cookies
Rate limiting
KVStore-based sliding window
GDPR
Consent, export, cascade delete
Multi-account
Session group KVStore
Transactional email
EmailClient + React Email
Prerequisites
AWS Blocks installed. Nuxt 4 SPA (the reference uses Nuxt, but the backend patterns are framework-agnostic). Node.js and bun.
Local dev works without an AWS account. No credentials required. No deployment required.
blocksapp/
├── aws-blocks/
│ ├── index.ts # All backend logic (~1300 lines)
│ ├── index.handler.ts # Lambda entry point
│ └── index.cdk.ts # CDK infrastructure
├── app/
│ ├── stores/ # Pinia stores (auth, org, accounts)
│ ├── composables/ # Stateless helpers
│ ├── middleware/ # Route guards
│ ├── layouts/ # 5 layouts
│ ├── pages/ # File-based routing
│ └── plugins/ # Auth bootstrap + CSRF
├── emails/ # React Email templates
└── server/middleware/ # Security headers
Enter fullscreen mode Exit fullscreen mode
File Purposeaws-blocks/index.ts
All backend logic
app/stores/auth.ts
Unified auth store
app/stores/org.ts
Org + RBAC store
app/stores/accounts.ts
Multi-account store
emails/*.tsx
React Email templates
server/middleware/security.ts
Security headers
Local Development Setup
Most auth systems require a deployed Cognito User Pool, registered OAuth apps, and real SMS delivery before you can run a single npm run dev.
AWS Blocks inverts that. Social login works locally without real OAuth credentials. Cognito verification codes print to the console. Set your env vars and the real providers kick in automatically.
Social Login — Env-Based Switching
const socialAuth = new AuthOIDC(scope, 'oidc', {
providers: [
process.env.GOOGLE_CLIENT_ID
? google({ clientId: () => googleClientId.get(), clientSecret: () => googleClientSecret.get() })
: stubIdp({ name: 'google' }),
process.env.GITHUB_CLIENT_ID
? github({ clientId: () => githubClientId.get(), clientSecret: () => githubClientSecret.get() })
: stubIdp({ name: 'github' }),
],
});
Enter fullscreen mode Exit fullscreen mode
When env vars are absent, stubIdp() handles the OAuth flow in-process. Real PKCE, real token exchange, real session cookie — just a deterministic local user instead of a human.
When you set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, the real Google provider activates. No code changes. No restart required.
Cognito Mock — Console Delivery
const auth = new AuthCognito(scope, 'cognito', {
signInWith: ['email'],
mfa: 'optional',
mfaTypes: ['SMS', 'TOTP', 'EMAIL'],
groups: ['users', 'admins'],
...(process.env.BLOCKS_SANDBOX !== 'true' && {
codeDelivery: async (destination, code, purpose) => {
console.log(`\n📧 ${purpose} code for ${destination}: ${code}\n`);
},
}),
});
Enter fullscreen mode Exit fullscreen mode
In local mode, verification and MFA codes print to the terminal. No AWS SES or SNS needed. Set BLOCKS_SANDBOX=true and real delivery kicks in.
The codeDelivery callback is the bridge between local dev and production. It intercepts Cognito’s verification flow and gives you the code where you can see it.
Dev Scripts
Script What it doesbun run dev
Start backend + frontend with hot reload
bun run sandbox
Deploy to AWS sandbox (real resources)
bun run email:dev
Preview email templates locally
bun run test:e2e
Run end-to-end tests
The dev script starts the Blocks dev server on port 4100 and the Nuxt frontend on port 3100. Hot reload works for both backend and frontend simultaneously.
The Federation Layer
Cognito handles email/password and MFA well. OIDC handles social login well. No single tool does both well.
The naive approach gives you two login flows, two session stores, and two user objects. The frontend has to know which provider authenticated the user. Every API method has to branch on provider type.
AWS Blocks solves this with a single function.
The Core Pattern
Here is the simplest version:
async function resolveAuthUser(context: any) {
try {
return await auth.requireAuth(context);
} catch {
return await socialAuth.requireAuth(context);
}
}
Enter fullscreen mode Exit fullscreen mode
That works. But the frontend has to check both providers on every request. The key is the authType short-circuit:
async function resolveAuthUser(context: any, authType?: 'cognito' | 'oidc') {
if (authType === 'oidc') return await socialAuth.requireAuth(context);
if (authType === 'cognito') return await auth.requireAuth(context);
try {
return await auth.requireAuth(context);
} catch {
return await socialAuth.requireAuth(context);
}
}
Enter fullscreen mode Exit fullscreen mode
When authType is known — from the activeAuthType cookie — it short-circuits to the right provider. Otherwise it tries Cognito first and falls back to OIDC.
Wiring It In
Every API method calls resolveAuthUser() instead of calling either block directly:
export const api = new ApiNamespace(scope, 'api', (context) => ({
async getProfile() {
const user = await resolveAuthUser(context);
return { username: user.username, email: user.email };
},
async adminOnly() {
const user = await auth.requireRole(context, 'admins');
return { message: `Welcome, admin ${user.username}` };
},
}));
Enter fullscreen mode Exit fullscreen mode
The frontend never branches on provider type in API calls. It calls api.getProfile() and gets a consistent user object back.
The Cookie Strategy
Three cookies work together:
Cookie Purpose Set byauth_* / oidc_*_session
Opaque session pointer (HMAC-signed)
AuthCognito / AuthOIDC
__group
Browser group ID for multi-account
Application code
activeAuthType
'cognito' or 'oidc'
Application code
Notice something important.
activeAuthType is a plain JS-readable cookie. No crypto, no HMAC, no server-side lookup. It carries no sensitive data. It just tells init() which provider to check first and signOut() which provider to call.
Dynamic Cookie Security
Cookie security attributes are computed dynamically per request:
function cookieAttrs(context: any) {
const sec = buildCookieSecurityAttrs({
crossDomain: process.env.BLOCKS_SANDBOX === 'true',
isLocalhost: isLoopbackRequest(context),
});
return `Path=/; HttpOnly; ${sec}`;
}
Enter fullscreen mode Exit fullscreen mode
Environment SameSite Secure Partitioned Local dev (HTTP) Lax no no Production (HTTPS) Lax yes no Cross-domain None yes yesOne function. Three environments. No manual configuration. buildCookieSecurityAttrs from @aws-blocks/auth-common/cookies handles the distinction automatically.
Multi-Tenancy and Organization Management
Organizations are the tenant boundary. All data queries are scoped to the active org via the __org cookie.
The Data Model
Three DistributedTables with GSIs for lookups:
const orgs = new DistributedTable(scope, 'orgs', {
schema: orgSchema,
key: { partitionKey: 'orgId' },
indexes: {
bySlug: { partitionKey: 'slug' },
byCreatedBy: { partitionKey: 'createdBy', sortKey: 'createdAt' },
},
});
Enter fullscreen mode Exit fullscreen mode
Table Schema Key GSIsorgs
orgId, name, slug, createdAt, createdBy
bySlug, byCreatedBy
orgMemberships
orgId, userId, role, joinedAt, invitedBy
byUser
orgInvitations
orgId, email, role, invitedBy, invitedAt, token, expiresAt
byToken, byEmail
The role field is an enum: owner > admin > member. The hierarchy is enforced inline in API methods.
Role Enforcement
Each org API method checks the caller’s role directly:
async updateMemberRole(orgId: string, userId: string, role: string) {
requireSameSite(context);
const user = await resolveAuthUser(context);
const membership = await orgMemberships.get({ orgId, userId: user.username });
if (!membership || membership.role !== 'owner') {
throw Object.assign(new Error('Only the owner can change roles'), { status: 403 });
}
// ...
}
Enter fullscreen mode Exit fullscreen mode
There is no middleware for RBAC. There is no decorator. There is no reflection. Each method reads the membership, checks the role, and throws if the caller lacks permission. It is verbose. It is clear. It is hard to get wrong.
The Invitation Flow
Invite by email, accept via token-based link:
- Owner or admin calls
inviteToOrg(orgId, email, role) - Backend validates role hierarchy, creates invitation with 7-day expiry, sends email
- Invitee clicks link in email, lands on
/accept-invite?token=... - Login or signup, then auto-accept and redirect to org page
Rate-limited to 10 invitations per minute per user. Resend and cancel supported.
Frontend RBAC
The Pinia org store exposes computed properties:
const currentRole = computed(() => currentOrgData.value?.role ?? null)
const isMember = computed(() => currentRole.value !== null)
const isOwner = computed(() => currentRole.value === 'owner')
const isAdmin = computed(() => currentRole.value === 'owner' || currentRole.value === 'admin')
Enter fullscreen mode Exit fullscreen mode
Pages use v-if for conditional rendering:
<UButton v-if="org.isAdmin" label="Invite" />
<UButton v-if="org.isOwner" label="Delete Org" color="error" />
Enter fullscreen mode Exit fullscreen mode
No provider-specific logic in templates. No role-checking utility functions. Just computed properties and v-if.
Multi-Account Session Switching
Each auth block issues one session cookie per sign-in. Users with work and personal accounts need to switch without logging out.
The Session Group Pattern
A __group cookie identifies the browser. Each login registers a session in the group with its full auth cookie string and authType:
const sessionGroups = new KVStore<{
sessions: Array<{
sessionId: string;
username: string;
linkedAt: number;
cookie: string;
authType: 'cognito' | 'oidc';
}>;
activeSessionId: string;
}>(scope, 'session-groups', {});
Enter fullscreen mode Exit fullscreen mode
The Flows
Register: after sign-in, the frontend calls registerSession(authType). Stores the session ID and provider type in the group. Caps at 3 linked accounts per browser.
Switch: switchSession(targetSessionId) swaps the cookie via Set-Cookie headers using cookieAttrs(). Returns { success, username, authType }. Hard page refresh destroys all Pinia state and reinitializes with the new cookie.
Unlink: removes a session from the group. Blocks unlinking the active session.
Sign-out: removes current session from the group. Only signs out the active provider.
The authType per session is critical. It tells the frontend which provider to call signOut() on.
Quick-Switch from the Login Page
Users can switch accounts from the login page without re-authenticating:
const sessions = await api.getGroupSessions();
// User clicks a session button
const result = await api.quickSwitchSession(sessionId);
setCookie('activeAuthType', result.authType);
window.location.href = '/dashboard';
Enter fullscreen mode Exit fullscreen mode
The login page shows linked accounts with a one-click switch. No credential re-entry. No OAuth redirect.
Security Infrastructure
CSRF, rate limiting, audit logging, and input validation — wired into every mutating API method. Not optional. Not configurable. Always on.
CSRF Protection
function requireSameSite(context: any) {
const customHeader = context.request.headers.get('x-requested-with');
if (customHeader) return;
const origin = context.request.headers.get('origin');
const referer = context.request.headers.get('referer');
if (!origin && !referer) return;
}
Enter fullscreen mode Exit fullscreen mode
Cross-origin JavaScript cannot set custom headers without CORS preflight. Requiring the x-requested-with header provides CSRF protection. SameSite cookies are the primary defense. This is defense-in-depth.
The header is injected by a Nuxt client plugin on every fetch:
// plugins/request-guard.client.ts
$fetch.defaults.headers['x-requested-with'] = 'XMLHttpRequest';
Enter fullscreen mode Exit fullscreen mode
Rate Limiting
KVStore-based sliding window:
async function checkRateLimit(
scope: string, identifier: string,
maxAttempts: number, windowMs: number
) {
const key = `${scope}:${identifier}`;
const now = Date.now();
const existing = await rateLimits.get(key);
if (!existing || now > existing.expiresAt) {
await rateLimits.put(key, { count: 1, expiresAt: now + windowMs });
return { allowed: true };
}
if (existing.count >= maxAttempts) {
return { allowed: false, retryAfterMs: existing.expiresAt - now };
}
await rateLimits.put(key, { count: existing.count + 1, expiresAt: existing.expiresAt });
return { allowed: true };
}
Enter fullscreen mode Exit fullscreen mode
Currently wired into inviteToOrg at 10 requests per minute per user. Expandable to sign-in, password reset, and account deletion.
Audit Logging
Every important mutation writes an audit event:
async function recordAuditEvent(
eventType: string, userId: string, context: any,
opts?: { targetUserId?: string; orgId?: string; metadata?: Record<string, unknown> }
) {
const eventId = Date.now().toString(36) + crypto.randomBytes(8).toString('hex');
const ip = context.request.headers.get('x-forwarded-for') || 'unknown';
await auditLog.put({
eventId, timestamp: Date.now(), eventType, userId,
targetUserId: opts?.targetUserId, orgId: opts?.orgId,
ip, metadata: opts?.metadata,
expiresAt: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
});
}
Enter fullscreen mode Exit fullscreen mode
90-day TTL. Queryable by user, org, and date range. Called from 15+ API methods including session management, org operations, GDPR actions, and auth events.
Input Validation
Zod schemas on every API method input:
const parsedEmail = z.string().email().parse(email);
const parsedName = z.string().min(1).max(100).parse(name);
const parsedOrgId = z.string().uuid().parse(orgId);
Enter fullscreen mode Exit fullscreen mode
Invalid inputs throw before any business logic runs. No SQL injection, no malformed data, no surprises.
Security Headers
Server middleware sets headers on every response:
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('X-XSS-Protection', '1; mode=block')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload')
res.setHeader('Content-Security-Policy', "default-src 'self'; ...")
}
Enter fullscreen mode Exit fullscreen mode
HSTS and CSP are only applied in production. Local dev runs without them to avoid breaking hot-reload.
Compliance and GDPR
Privacy is a first-class concern. Consent recording, data export, and account deletion are built into the API layer.
Consent Recording
async recordConsent(consentType: string, granted: boolean) {
requireSameSite(context);
const user = await resolveAuthUser(context);
const existing = await userConsents.get(user.username) || [];
existing.push({ consentType, granted, timestamp: Date.now(), ip: '...' });
await userConsents.put(user.username, existing);
await recordAuditEvent('consent_' + (granted ? 'granted' : 'revoked'), user.username, context);
return { success: true };
}
Enter fullscreen mode Exit fullscreen mode
Consent is stored per user with timestamp and IP. The audit log captures the event. The frontend shows a ConsentCheckbox on signup with links to the privacy policy.
Data Export
async exportUserData() {
const user = await resolveAuthUser(context);
return {
profile: await users.get({ userId: user.username }),
consents: await userConsents.get(user.username) || [],
memberships: await orgMemberships.query('byUser', { userId: user.username }),
auditEvents: await auditLog.query('byUser', { userId: user.username }),
};
}
Enter fullscreen mode Exit fullscreen mode
One call returns everything. Profile, consents, memberships, audit events. The user can download it, review it, or send it to a regulator.
Account Deletion
Cascade delete across all stores:
async deleteAccount() {
requireSameSite(context);
const user = await resolveAuthUser(context);
const memberships = await orgMemberships.query('byUser', { userId: user.username });
for (const m of memberships) {
await orgMemberships.delete({ orgId: m.orgId, userId: user.username });
}
await userConsents.delete(user.username);
await auth.deleteUser(context);
await recordAuditEvent('account_deleted', user.username, context);
return { success: true };
}
Enter fullscreen mode Exit fullscreen mode
Remove from all orgs. Remove from all sessions. Remove consents. Delete the Cognito user. Record the event. The frontend requires typing “DELETE” to confirm.
Transactional Email
Branded, responsive emails delivered through Amazon SES. Templates built with React Email. Rendered to HTML on the server, sent via the EmailClient block.
The Setup
Five templates in emails/:
ConfirmationEmail
Sign-up
Verification code
PasswordResetEmail
Password reset
Reset code
OrgInvitationEmail
Org invite
Accept button + org name
NewSignInEmail
Sign-in
Timestamp + IP + warning
PasswordChangedEmail
Password change
Timestamp + IP + warning
Lazy Initialization
let _emailClient: EmailClient | null = null;
async function getEmailClient(): Promise<EmailClient> {
if (!_emailClient) {
const from = await emailFromAddress.get();
const replyTo = await emailReplyToAddress.get();
_emailClient = new EmailClient(scope, 'email', {
fromAddress: from,
replyTo: replyTo ? [replyTo] : undefined,
});
}
return _emailClient;
}
Enter fullscreen mode Exit fullscreen mode
The from and reply-to addresses are stored as AppSetting values. The client initializes once and reuses across requests.
Template Example
The org invitation template:
export function OrgInvitationEmail({ orgName, inviterEmail, inviteUrl }: OrgInvitationEmailProps) {
return (
<Html lang="en">
<Head />
<Preview>You've been invited to {orgName}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>You've been invited</Heading>
<Text style={text}>
<strong>{inviterEmail}</strong> has invited you to join <strong>{orgName}</strong>.
</Text>
<div style={buttonContainer}>
<Button href={inviteUrl} style={button}>Accept Invitation</Button>
</div>
<Text style={footer}>This invitation expires in 7 days.</Text>
</Container>
</Body>
</Html>
);
}
Enter fullscreen mode Exit fullscreen mode
Sending
async function doSendInvitationEmail(email: string, orgName: string, inviterEmail: string, token: string) {
const inviteUrl = `${process.env.APP_URL || 'http://localhost:3000'}/accept-invite?token=${token}`;
const html = await render(React.createElement(OrgInvitationEmail, { orgName, inviterEmail, inviteUrl }));
const client = await getEmailClient();
await client.send({
to: email,
subject: `You've been invited to ${orgName}`,
body: `${inviterEmail} has invited you to join ${orgName}. Accept: ${inviteUrl}`,
html,
});
}
Enter fullscreen mode Exit fullscreen mode
The body field is a plain-text fallback for email clients that do not render HTML. The html field is the React Email output.
Security Notifications
Auth events trigger automatic emails:
async recordAuthEvent(eventType: string) {
const user = await resolveAuthUser(context);
await recordAuditEvent(parsedType, user.username, context);
if (parsedType === 'sign_in' || parsedType === 'password_changed') {
const attributes = await auth.fetchUserAttributes(context);
if (attributes.email) {
const html = await render(React.createElement(NewSignInEmail, {
email: attributes.email, timestamp, ip
}));
await client.send({ to: attributes.email, subject: '...', body: '...', html });
}
}
return { success: true };
}
Enter fullscreen mode Exit fullscreen mode
Sign-in notifications include the timestamp and IP address. Password change notifications include the same. Both display a red warning if the action was unauthorized.
Failures are silently caught. Auth operations are never blocked by email issues.
Local Email Preview
Run bun run email:dev to start the React Email dev server. Templates render in the browser with live hot-reload. No SES credentials needed. No email sent.
The Frontend Architecture
Three Pinia stores, three middleware files, and the principle that the frontend never touches auth infrastructure.
The Three Stores
auth.ts — unified auth. Wraps Cognito and OIDC behind a single interface. init() reads the activeAuthType cookie and checks the correct provider first. signOut() only signs out the active provider.
async function init() {
const activeType = getCookie('activeAuthType');
if (activeType === 'oidc') {
if (await socialAuthApi.checkAuth()) { user = await socialAuthApi.getCurrentUser(); return; }
if (await authApi.checkAuth()) { user = await authApi.getCurrentUser(); return; }
} else {
if (await authApi.checkAuth()) { user = await authApi.getCurrentUser(); return; }
if (await socialAuthApi.checkAuth()) { user = await socialAuthApi.getCurrentUser(); return; }
}
user = null;
}
Enter fullscreen mode Exit fullscreen mode
org.ts — organizations, members, roles. Exposes isOwner, isAdmin, isMember computed properties. checkOrgAccess() gates /org/* routes.
accounts.ts — multi-account. loadAccounts(), registerSession(), switchAccount(), unlinkAccount(). The switchAccount() method returns the API result so the frontend can set the activeAuthType cookie before redirect.
The Middleware Chain
auth.global.ts (every route, org membership check for /org/*)
└→ admin.ts (Cognito group check for /admin/*)
└→ guest.ts (redirect authenticated users away from auth pages)
Enter fullscreen mode Exit fullscreen mode
auth.global.ts runs on every route. It calls auth.init(), checks authentication, and calls orgStore.checkOrgAccess() for /org/* routes. Named middleware only adds additional checks on top.
Deduplication
A module-level inflight promise prevents concurrent init() calls. When multiple components mount simultaneously and all call init(), only one RPC fires. The others wait for the same promise.
let inflight: Promise<void> | null = null;
async function init() {
if (inflight) return inflight;
inflight = (async () => {
// ... check providers, set user
})().finally(() => { inflight = null; });
return inflight;
}
Enter fullscreen mode Exit fullscreen mode
Components access auth.user and auth.isAuthenticated. No redundant network requests. No race conditions between middleware and components.
Five Layouts
Layout Use for Sidebardefault
Public pages (landing, privacy)
No
auth
Login, signup, confirm, forgot
No
dashboard
Top-level pages (org list, create org)
No
org
Org-scoped pages (members, settings)
Yes
profile
User profile pages
Yes
Each layout serves a distinct purpose. The org layout shows a loading spinner while org.rbacLoading is true, then renders RbacUnauthorized if the user is not a member.
Plugins
Plugin Purposeauth.client.ts
Bootstrap auth on app load, provide $api, $authApi, $socialAuthApi
request-guard.client.ts
Inject x-requested-with header on every fetch for CSRF
The request-guard plugin runs on every client-side fetch. It sets the custom header that requireSameSite() checks on the backend.
The API Surface
38 methods in one namespace. The backend provides everything the frontend needs.
Category Methods ProfilegetProfile, authSource, setValue, getValue
MFA
mfaSetup, confirmMfaSetup, disableMfa, getMfaPreferences
Sessions
registerSession, linkedAccounts, switchSession, quickSwitchSession, unlinkSession, getGroupSessions, removeCurrentSessionFromGroup
Organizations
createOrg, listMyOrgs, getOrg, updateOrg, deleteOrg
Members
getOrgMembers, inviteToOrg, removeFromOrg, updateMemberRole, getOrgInvitations, acceptInvitation
Org scope
setActiveOrg, getActiveOrg
Security
recordConsent, getConsentStatus, exportUserData, deleteAccount, recordAuthEvent
Admin
adminOnly, getAuditLog, getOrgAuditLog
Email
sendConfirmationEmail, sendPasswordResetEmail, sendOrgInvitationEmail
Security Wiring
Check MethodsrequireSameSite()
12 mutating methods
requireRateLimit()
inviteToOrg (10/min)
recordAuditEvent()
15+ methods
Zod validation
All inputs
Every mutating method calls requireSameSite(). Every sensitive method calls recordAuditEvent(). Every input is validated with Zod. No exceptions.
What You Didn’t Build
The reference implementation is ~1300 lines of backend code. Here is what that code does not include:
- No Cognito User Pool setup or configuration
- No OAuth flow implementation (PKCE, token exchange, callback handling)
- No MFA challenge state machine
- No session cookie HMAC signing or verification
- No CSRF token generation or validation
- No rate limiting infrastructure
- No audit event storage or querying
- No email template rendering or delivery
- No role hierarchy enforcement
- No invitation token generation or expiry
- No cookie security attribute computation
- No API namespace transport or client generation
All of that is provided by the Building Blocks. The 1300 lines are your application logic — the federation glue, the org management, the RBAC checks, the GDPR compliance. Not the infrastructure.
What’s Next
-
Extensible RBAC — the reference uses hardcoded three-tier roles. An optional
resource:actionpermission system adds custom roles, fine-grained permissions across 6 resources, and a roles management UI. See the RBAC spec in.agents/RBAC.md. -
Rate limiting expansion — sign-in attempts (5/min), password reset (3/min), account deletion (1/hr). The
checkRateLimit()function is already built. It needs to be wired into more methods. - Testing — currently 1 trivial KV smoke test. Security tests (CSRF, rate limiting, session fixation), org tests (privilege escalation, last-owner protection), GDPR tests (consent, export, erasure), and E2E auth tests.
- WCAG audit — keyboard navigation, color contrast, aria labels across all 13 pages.
Learn More
- AWS Blocks — Infrastructure from Code framework
- AWS Blocks Authentication — AWS Auth Building Blocks
- aws-blocks-auth — Reference implementation
- React Email — Email template framework
- Nuxt UI — Vue component library
답글 남기기