Copy this and paste it into the new app's chat as one message.
# PROMPT: Build a Passwordless Email-Code Login System (AWS SES + Base44)
> Copy everything below the line into the new Base44 app's chat as a single message.
---
Build a **passwordless authentication system** for this app. Do not use Base44's built-in login page and do not build passwords. The entire flow is: **collect email → email a 6-digit code via AWS SES → collect the code → verify it → issue a session token → resolve that email's subscription tier**.
This is a proven architecture. Follow it exactly.
## Why this design
The app must be publicly reachable (marketing pages, webinar landing pages, broker-branded subdomains) while still knowing who each visitor is and what they've paid for. Passwords create friction and support load. A one-time code proves email ownership, and email ownership is the join key to the billing record. There is no password to reset, leak, or store.
## Architecture overview
```
Frontend gate component
│ step 1: email ──► sendVerificationCode ──► AWS SES ──► user's inbox
│ step 2: code ──► verifyEmailCode ──► session_token + user{tier}
│ │
│ localStorage session
│ │
└─ on every page load ──────────► validateSession ──► fresh user{tier}
```
Three backend functions, two entities, one frontend component. Tier is **never** trusted from the browser — it is resolved server-side from the billing record on every validation.
---
## STEP 1 — Entities
### `LoginCode`
Short-lived one-time codes. Nothing else reads this.
```json
{
"name": "LoginCode",
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"code": { "type": "string", "description": "6-digit verification code" },
"expires_at": { "type": "string", "format": "date-time" },
"used": { "type": "boolean", "default": false }
},
"required": ["email", "code", "expires_at"],
"indexes": [{ "fields": ["email", "code"] }],
"rls": {
"create": { "user_condition": { "role": "admin" } },
"read": { "user_condition": { "role": "admin" } },
"update": { "user_condition": { "role": "admin" } },
"delete": { "user_condition": { "role": "admin" } }
}
}
```
**The RLS lockdown is mandatory.** Codes and session tokens are bearer credentials. If any authenticated visitor can list these records, they can read another person's code or steal a live token and impersonate them. Backend functions reach these records with `asServiceRole`, which bypasses RLS — so locking them to admin costs the app nothing and closes the hole completely.
### `SessionToken`
Server-side session records. The browser holds only an opaque string; the truth lives here so a session can be revoked instantly.
```json
{
"name": "SessionToken",
"type": "object",
"properties": {
"email": { "type": "string" },
"token": { "type": "string", "description": "SHA-256 hash of the token — never the raw value" },
"expires_at": { "type": "string", "format": "date-time" },
"is_valid": { "type": "boolean", "default": true }
},
"required": ["email", "token", "expires_at"],
"indexes": [{ "fields": ["token"], "unique": true }],
"rls": {
"create": { "user_condition": { "role": "admin" } },
"read": { "user_condition": { "role": "admin" } },
"update": { "user_condition": { "role": "admin" } },
"delete": { "user_condition": { "role": "admin" } }
}
}
```
Store the **hash**, not the token. If the database is ever exposed, hashes are not usable as credentials — the same reason passwords are hashed.
### `Customer` — the tier source of truth
Whatever entity holds billing state, it is the **only** authority on what a user can access. Stripe webhooks write to it; auth functions only read it. Minimum fields: `email`, `full_name`, `current_tier`, `subscription_active`, `subscription_status`, `access_until`, `email_verified`, `email_verified_at`.
Never let a marketing platform (Mailchimp, Kajabi, etc.) decide tier. Those systems are eventually-consistent and tag-based; treating them as authoritative silently grants or revokes paid access when a tag drifts. Sync to them fire-and-forget, and read tier only from the billing record.
---
## STEP 2 — AWS SES credentials and setup
This is the part that needs real AWS work before any code runs.
### Create the sending identity
1. AWS Console → **Amazon SES** → pick a region and stay in it (`us-east-1` is the reference).
2. **Verified identities** → verify your **sending domain** (not just one address). Add the DKIM CNAME records AWS gives you to your DNS. Domain-level DKIM is what keeps codes out of spam — a bare verified address without DKIM gets filtered heavily.
3. **Request production access.** A brand-new SES account is in *sandbox*: it can only send to addresses you have individually verified, which silently breaks the flow for every real user. Filling in the production-access request is not optional.
### Create a least-privilege IAM user
IAM → Users → create a **programmatic-access** user with this inline policy and nothing more:
```json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*"
}]
}
```
Scope it to just SES send. If these keys leak, the damage is capped at sending email — not reading your S3 or spinning up compute.
### Register the two secrets in Base44
Ask the app owner to set these (values are entered by them; you never see or hardcode them):
| Secret name | Where it comes from |
|---|---|
| `AWS_ACCESS_KEY_ID` | IAM user's access key ID |
| `AWS_SECRET_ACCESS_KEY` | IAM user's secret access key (shown once at creation) |
Declare them with the `set_secrets` tool **before** writing the function that reads them, then read them in backend code via `Deno.env.get('AWS_ACCESS_KEY_ID')`. Also decide the `Source` sender address now — it must be on the verified domain, e.g. `noreply@yourdomain.com`. SES rejects any other From address.
---
## STEP 3 — Backend function: `sendVerificationCode`
File: `base44/functions/sendVerificationCode/entry.ts`
```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.48';
import { SESClient, SendEmailCommand } from 'npm:@aws-sdk/client-ses@3.540.0';
const SES_REGION = 'us-east-1';
const FROM_ADDRESS = 'noreply@yourdomain.com'; // MUST be on the SES-verified domain
const BRAND_NAME = 'Your Brand';
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
try {
const base44 = createClientFromRequest(req);
const { email } = await req.json();
if (!email || !email.includes('@')) {
return Response.json({ error: 'Valid email required' }, { status: 400 });
}
const normalizedEmail = email.trim().toLowerCase();
// 6-digit code, 10-minute TTL
const code = String(Math.floor(100000 + Math.random() * 900000));
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
// Purge only EXPIRED/USED codes for this email — never the fresh ones.
// Deleting all prior codes breaks "Resend": the user typically types the
// FIRST code they received, and that record must still validate.
try {
const existing = await base44.asServiceRole.entities.LoginCode.filter({ email: normalizedEmail });
const now = new Date();
for (const old of existing) {
if (old.used === true || (old.expires_at && new Date(old.expires_at) < now)) {
try { await base44.asServiceRole.entities.LoginCode.delete(old.id); } catch (_) {}
}
}
} catch (e) {
console.warn('[sendVerificationCode] cleanup skipped:', e.message);
}
await base44.asServiceRole.entities.LoginCode.create({
email: normalizedEmail, code, expires_at: expiresAt, used: false,
});
const htmlBody = `<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f4f4f7;font-family:Arial,sans-serif;color:#333;">
<div style="max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#111;padding:28px 24px;text-align:center;border-radius:12px 12px 0 0;">
<span style="color:#fff;font-size:22px;font-weight:800;">${BRAND_NAME}</span>
</div>
<div style="background:#fff;padding:32px 28px;">
<h2 style="margin:0 0 12px;color:#1a2332;">Your Verification Code</h2>
<p style="font-size:15px;color:#555;">Enter this code to finish signing in.</p>
<div style="background:#f8f9fb;border-left:4px solid #111;padding:18px 20px;margin:24px 0;">
<p style="margin:0 0 8px;font-size:13px;color:#888;">Your one-time code:</p>
<div style="font-size:36px;font-weight:700;letter-spacing:10px;">${code}</div>
</div>
<p style="font-size:13px;color:#888;">This code expires in <strong>10 minutes</strong>.</p>
<p style="font-size:13px;color:#888;">Didn't request it? You can ignore this email.</p>
</div>
</div>
</body></html>`;
let emailSent = false;
try {
const ses = new SESClient({
region: SES_REGION,
credentials: {
accessKeyId: Deno.env.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: Deno.env.get('AWS_SECRET_ACCESS_KEY'),
},
});
await ses.send(new SendEmailCommand({
Source: `${BRAND_NAME} <${FROM_ADDRESS}>`,
Destination: { ToAddresses: [normalizedEmail] },
Message: {
Subject: { Data: `Your ${BRAND_NAME} Verification Code` },
Body: { Html: { Data: htmlBody } },
},
}));
emailSent = true;
} catch (emailErr) {
console.error('[sendVerificationCode] SES failed:', emailErr.message);
}
// Report delivery failure WITHOUT revealing the code — the UI shows a
// "check your address" hint instead of leaving the user staring at an
// empty inbox with no explanation.
return Response.json({ success: true, email_sent: emailSent });
} catch (error) {
console.error('[sendVerificationCode] Error:', error);
return Response.json({ error: error.message }, { status: 500 });
}
});
```
Note the shape of the response: it always returns `success: true` whether or not the address exists. Returning "no such user" here would turn the endpoint into an account-enumeration oracle. The delivery-failure signal (`email_sent: false`) is a UI hint, not an account existence check.
---
## STEP 4 — Backend function: `verifyEmailCode`
File: `base44/functions/verifyEmailCode/entry.ts`
```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.48';
const ADMIN_EMAILS = new Set(['owner@yourdomain.com']);
const TIER_RANK = { free: 0, basic: 1, pro: 2, premium: 3 };
function normalizeTier(raw) {
if (!raw) return 'free';
return raw.toLowerCase().trim();
}
async function sha256(text) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
try {
const base44 = createClientFromRequest(req);
const { email, code } = await req.json();
if (!email || !code) {
return Response.json({ error: 'Email and code required' }, { status: 400 });
}
const normalizedEmail = email.trim().toLowerCase();
const codeStr = String(code).trim();
// ── 1. Validate the code ──
const codes = await base44.asServiceRole.entities.LoginCode.filter({
email: normalizedEmail, code: codeStr,
});
if (!codes || codes.length === 0) {
return Response.json({ verified: false, error: 'Invalid code. Please check and try again.' }, { status: 401 });
}
const now = new Date();
const record = codes.find(c => !c.used && (!c.expires_at || new Date(c.expires_at) >= now));
if (!record) {
for (const c of codes) { try { await base44.asServiceRole.entities.LoginCode.delete(c.id); } catch (_) {} }
return Response.json({ verified: false, error: 'Code expired. Please request a new one.' }, { status: 401 });
}
// Burn every code for this email — a code must be single-use, or an
// intercepted email stays replayable for the rest of its 10-minute window.
for (const c of codes) { try { await base44.asServiceRole.entities.LoginCode.delete(c.id); } catch (_) {} }
// ── 2. Issue the session: raw token to the browser, hash to the DB ──
const sessionToken = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '');
const tokenHash = await sha256(sessionToken);
try {
const old = await base44.asServiceRole.entities.SessionToken.filter({ email: normalizedEmail });
for (const t of old) { try { await base44.asServiceRole.entities.SessionToken.delete(t.id); } catch (_) {} }
} catch (_) {}
await base44.asServiceRole.entities.SessionToken.create({
email: normalizedEmail,
token: tokenHash,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
is_valid: true,
});
// ── 3. Admin override ──
if (ADMIN_EMAILS.has(normalizedEmail)) {
return Response.json({
verified: true,
session_token: sessionToken,
user: { email: normalizedEmail, full_name: 'Administrator', tier: 'admin' },
});
}
// ── 4. Resolve tier from the billing record ──
let tier = 'free';
let fullName = '';
let existing = null;
try {
const customers = await base44.asServiceRole.entities.Customer.filter({ email: normalizedEmail });
if (customers?.length > 0) {
existing = customers[0];
tier = normalizeTier(existing.current_tier);
fullName = existing.full_name || '';
// Cancelled-but-paid-through: honour access_until, then demote once.
if (existing.subscription_status === 'cancelled_pending' && existing.access_until
&& new Date(existing.access_until) < new Date()) {
tier = 'free';
try {
await base44.asServiceRole.entities.Customer.update(existing.id, {
current_tier: 'free', subscription_active: false,
subscription_status: 'cancelled', access_until: null,
});
} catch (_) {}
}
}
} catch (e) {
console.error('[verifyEmailCode] Customer lookup failed:', e.message);
}
// ── 5. First-time visitor: create the free record ──
if (!existing) {
try {
await base44.asServiceRole.entities.Customer.create({
email: normalizedEmail, full_name: '', current_tier: 'free',
subscription_active: false, subscription_status: 'inactive',
email_verified: true, email_verified_at: new Date().toISOString(),
});
} catch (e) {
console.warn('[verifyEmailCode] Customer create skipped:', e.message);
}
} else if (!existing.email_verified) {
try {
await base44.asServiceRole.entities.Customer.update(existing.id, {
email_verified: true, email_verified_at: new Date().toISOString(),
});
} catch (_) {}
}
return Response.json({
verified: true,
session_token: sessionToken,
user: { email: normalizedEmail, full_name: fullName, tier },
});
} catch (error) {
console.error('[verifyEmailCode] Error:', error);
return Response.json({ error: error.message }, { status: 500 });
}
});
```
Creating the free `Customer` record on first verification is what makes the funnel work: a visitor who came for one webinar is now a known, email-verified contact who can be upgraded later, with no separate signup step.
---
## STEP 5 — Backend function: `validateSession`
File: `base44/functions/validateSession/entry.ts`
```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.48';
const ADMIN_EMAILS = new Set(['owner@yourdomain.com']);
const ROLLING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // extends on each use
const MAX_SESSION_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000; // hard cap
function normalizeTier(raw) {
if (!raw) return 'free';
return raw.toLowerCase().trim();
}
async function sha256(text) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
try {
const base44 = createClientFromRequest(req);
const { session_token } = await req.json();
if (!session_token) return Response.json({ valid: false }, { status: 401 });
const tokenHash = await sha256(session_token);
const tokens = await base44.asServiceRole.entities.SessionToken.filter({ token: tokenHash });
if (!tokens || tokens.length === 0) return Response.json({ valid: false }, { status: 401 });
const record = tokens[0];
if (!record.is_valid) {
return Response.json({ valid: false, error: 'Session revoked' }, { status: 401 });
}
if (new Date(record.expires_at) < new Date()) {
try { await base44.asServiceRole.entities.SessionToken.delete(record.id); } catch (_) {}
return Response.json({ valid: false, error: 'Session expired' }, { status: 401 });
}
// Absolute lifetime cap. Without it, a rolling window means an active
// session never expires — one stolen token would be valid forever.
if (Date.now() - new Date(record.created_date).getTime() > MAX_SESSION_LIFETIME_MS) {
try { await base44.asServiceRole.entities.SessionToken.update(record.id, { is_valid: false }); } catch (_) {}
return Response.json({ valid: false, error: 'Please sign in again' }, { status: 401 });
}
// Rolling extension — active users are never logged out mid-use.
try {
await base44.asServiceRole.entities.SessionToken.update(record.id, {
expires_at: new Date(Date.now() + ROLLING_WINDOW_MS).toISOString(),
});
} catch (_) {}
const normalizedEmail = record.email.toLowerCase().trim();
if (ADMIN_EMAILS.has(normalizedEmail)) {
return Response.json({
valid: true,
user: { email: normalizedEmail, full_name: 'Administrator', tier: 'admin' },
});
}
// Re-read tier on EVERY validation, so an upgrade or cancellation takes
// effect on the next page load instead of at the next login.
let tier = 'free';
let fullName = '';
try {
const customers = await base44.asServiceRole.entities.Customer.filter({ email: normalizedEmail });
if (customers?.length > 0) {
tier = normalizeTier(customers[0].current_tier);
fullName = customers[0].full_name || '';
}
} catch (e) {
// Never downgrade a paying user because of a transient read failure —
// signal "retry", don't answer "free".
console.error('[validateSession] Customer lookup failed:', e.message);
return Response.json({ valid: false, error: 'Temporary error, please retry', retry: true }, { status: 503 });
}
return Response.json({ valid: true, user: { email: normalizedEmail, full_name: fullName, tier } });
} catch (error) {
console.error('[validateSession] Error:', error);
return Response.json({ error: error.message }, { status: 500 });
}
});
```
The 503-on-read-failure branch matters more than it looks. If a database hiccup returned `tier: 'free'`, a premium subscriber would abruptly see paywalls, and support tickets would follow. Distinguishing "unknown right now" from "not entitled" prevents that.
---
## STEP 6 — Frontend gate component
File: `src/components/auth/EmailCodeGate.jsx`. Two steps in one component, driven by a `step` state.
```jsx
import React, { useState, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export default function EmailCodeGate({ onVerified, title = 'Sign in' }) {
const [step, setStep] = useState('email');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [emailFailed, setEmailFailed] = useState(false);
const codeRef = useRef(null);
const sendCode = async () => {
setLoading(true); setError('');
try {
const res = await base44.functions.invoke('sendVerificationCode', { email });
setEmailFailed(res.data?.email_sent === false);
setStep('code');
setTimeout(() => codeRef.current?.focus(), 100);
} catch (e) {
setError(e?.response?.data?.error || 'Failed to send code.');
} finally { setLoading(false); }
};
const verifyCode = async () => {
if (code.length !== 6) { setError('Enter the 6-digit code.'); return; }
setLoading(true); setError('');
try {
const res = await base44.functions.invoke('verifyEmailCode', { email, code });
if (res.data?.verified) {
const session = {
email: res.data.user.email,
full_name: res.data.user.full_name,
tier: res.data.user.tier,
session_token: res.data.session_token,
verified_at: new Date().toISOString(),
};
localStorage.setItem('app_session', JSON.stringify(session));
onVerified(session);
} else {
setError(res.data?.error || 'Invalid code.');
}
} catch (e) {
setError(e?.response?.data?.error || 'Verification failed.');
} finally { setLoading(false); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="w-full max-w-md rounded-2xl bg-card p-8 shadow-2xl">
{step === 'email' ? (
<>
<h2 className="mb-2 text-xl font-bold">{title}</h2>
<p className="mb-5 text-sm text-muted-foreground">
Enter your email and we'll send a 6-digit code. No password needed.
</p>
<Input
type="email" value={email} placeholder="your@email.com"
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && email && sendCode()}
/>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
<Button className="mt-4 w-full" disabled={loading || !email} onClick={sendCode}>
{loading ? 'Sending…' : 'Send my code'}
</Button>
</>
) : (
<>
<h2 className="mb-2 text-xl font-bold">Check your email</h2>
{emailFailed ? (
<div className="mb-5 rounded-lg border border-amber-400 bg-amber-50 p-3 text-sm text-amber-900">
We couldn't deliver to <strong>{email}</strong>. Double-check the address and try again.
</div>
) : (
<p className="mb-5 text-sm text-muted-foreground">
We sent a 6-digit code to <strong>{email}</strong>
</p>
)}
<Input
ref={codeRef} type="text" inputMode="numeric" maxLength={6}
placeholder="000000" value={code}
className="text-center text-2xl font-bold tracking-[0.5em]"
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => e.key === 'Enter' && verifyCode()}
/>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
<Button className="mt-4 w-full" disabled={loading || code.length !== 6} onClick={verifyCode}>
{loading ? 'Verifying…' : 'Continue'}
</Button>
<div className="mt-4 flex justify-center gap-3 text-sm text-muted-foreground">
<button className="underline" disabled={loading} onClick={sendCode}>Resend code</button>
<span>•</span>
<button className="underline" onClick={() => { setStep('email'); setCode(''); setError(''); }}>
Change email
</button>
</div>
</>
)}
</div>
</div>
);
}
```
Details that decide whether this actually gets used: strip non-digits from the code field, set `inputMode="numeric"` so phones show a number pad, autofocus the code input when the step changes, submit on Enter, and always offer **Resend** and **Change email** — a typo'd address is the single most common failure and without an escape hatch the user is stuck.
---
## STEP 7 — Session rehydration hook
File: `src/hooks/useSession.jsx`. Every gated page reads tier from here, never from `localStorage` directly.
```jsx
import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
export function useSession() {
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
const stored = localStorage.getItem('app_session');
if (!stored) { setLoading(false); return; }
try {
const { session_token } = JSON.parse(stored);
const res = await base44.functions.invoke('validateSession', { session_token });
if (res.data?.valid) {
const fresh = { ...res.data.user, session_token };
localStorage.setItem('app_session', JSON.stringify(fresh));
setSession(fresh);
} else if (!res.data?.retry) {
localStorage.removeItem('app_session');
}
} catch (_) {
// transient failure: keep the stored session, don't force a re-login
} finally { setLoading(false); }
})();
}, []);
const logout = () => { localStorage.removeItem('app_session'); setSession(null); };
return { session, loading, logout, isAuthenticated: !!session };
}
```
The tier that gates features must come from this hook's server response. A tier value read straight out of `localStorage` is user-editable — anyone can open devtools and promote themselves to `premium`. `localStorage` holds the opaque token; the server decides what it's worth.
---
## Build order
1. `set_secrets` for `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, and confirm SES domain verification + production access.
2. Create `LoginCode` and `SessionToken` entities (with the admin-only RLS above).
3. Write `sendVerificationCode`, then test it with `test_backend_function` using a real inbox.
4. Write `verifyEmailCode`, then test with the code that arrives.
5. Write `validateSession`, then test with the returned token.
6. Build `EmailCodeGate` and `useSession`, and wire the gate into the pages that need it.
## Non-negotiables
- Codes: 6 digits, 10-minute TTL, single-use, deleted after verification.
- `LoginCode` and `SessionToken` are admin-only in RLS; functions use `asServiceRole`.
- Session tokens are stored hashed, with a rolling 7-day window and a 90-day hard cap.
- Tier is resolved server-side from the billing record on every `validateSession` call.
- Never return the code in an API response, and never reveal whether an email is registered.
- Never let a marketing/CRM platform decide entitlement — sync to it fire-and-forget only.