Auth System
tripplan.ing implements authentication from scratch using email one-time passwords (OTP) and KV-backed sessions. The entire system is ~130 lines in auth.ts with no external auth library. This design was chosen for Cloudflare Workers compatibility — no Node.js crypto module, no session stores, no OAuth providers.
Overview
auth.ts — OTP generation, verification, session CRUD
platform/auth.ts — Platform operator role checks
hooks.server.ts — Route guards and session parsingTwo separate auth contexts exist:
- Event auth: Email OTP for attendees and organizers accessing event sites
- Platform auth: Same OTP sign-in, but gated by operator role checks for
/platform/*routes
OTP flow
Generation
export function generateOtp(): string {
const array = new Uint32Array(1);
crypto.getRandomValues(array); // Web Crypto API (works on CF Workers)
return String(array[0] % 1000000).padStart(6, '0');
}The OTP is stored in KV with a 10-minute TTL:
await kv.put(`otp:${email}`, JSON.stringify({ otp, attempts: 0 }), {
expirationTtl: 600 // 10 minutes
});Rate limiting
Two rate limits protect against abuse:
| Limit | Scope | Threshold | Window |
|---|---|---|---|
| Generation | Per email | 5 OTPs | 10 minutes |
| Verification | Per OTP | 3 attempts | Until OTP expires |
Generation rate limiting uses a counter in KV:
export async function checkOtpGenerateLimit(kv: KvStore, email: string): Promise<boolean> {
const key = `otp-gen-count:${email}`;
const val = await kv.get(key, 'text');
const count = val ? parseInt(val, 10) : 0;
return count >= 5; // true = rate limited
}Each generation increments the counter (same 10-minute TTL window). The counter auto-expires, so no cleanup is needed.
Verification
OTP verification uses constant-time string comparison to prevent timing attacks:
async function timingSafeEqual(a: string, b: string): Promise<boolean> {
const encoder = new TextEncoder();
const aBuf = encoder.encode(a);
const bBuf = encoder.encode(b);
if (aBuf.length !== bBuf.length) return false;
// Uses SubtleCrypto HMAC for constant-time comparison
const key = await crypto.subtle.importKey(
'raw', aBuf, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, bBuf);
const expected = await crypto.subtle.sign('HMAC', key, aBuf);
// Byte-by-byte XOR comparison
let diff = 0;
for (let i = 0; i < sigArr.length; i++) diff |= sigArr[i] ^ expArr[i];
return diff === 0;
}This avoids crypto.timingSafeEqual() which requires Node.js — the SubtleCrypto approach works on Cloudflare Workers.
The verification flow:
- Read stored OTP from KV
- If missing → expired (return
{ expired: true }) - If attempts >= 3 → too many attempts (delete OTP, return
{ tooManyAttempts: true }) - Increment attempts counter before comparison (closes race window)
- Compare using timing-safe equality
- If match → delete OTP, return
{ valid: true } - If no match → return
{ valid: false }(attempts already incremented)
Sessions
Creation
After successful OTP verification, a session is created:
export async function createSession(kv: KvStore, email: string): Promise<string> {
const sessionId = crypto.randomUUID();
const gen = await getSessionGeneration(kv, email);
await kv.put(`session:${sessionId}`, JSON.stringify({ email, gen }), {
expirationTtl: 604800 // 7 days
});
return sessionId;
}The session ID is set as an HTTP-only cookie. Sessions include a generation counter for bulk invalidation.
Validation
On each request, hooks.server.ts validates the session:
if (sessionId) {
event.locals.session = await getSession(env.kv, sessionId);
}getSession() checks both existence and generation:
export async function getSession(kv: KvStore, sessionId: string) {
const session = await kv.get(`session:${sessionId}`, 'json');
if (!session) return null;
// Check generation — reject sessions older than current generation
if (session.gen !== undefined) {
const currentGen = await getSessionGeneration(kv, session.email);
if (session.gen < currentGen) {
await kv.delete(`session:${sessionId}`);
return null;
}
}
return { email: session.email };
}Bulk invalidation ("log out everywhere")
Calling invalidateUserSessions() increments the user's generation counter:
export async function invalidateUserSessions(kv: KvStore, email: string) {
const current = await getSessionGeneration(kv, email);
await kv.put(`session-gen:${email}`, String(current + 1));
}All existing sessions with a lower generation are rejected on next use. This avoids scanning all KV keys — each session self-validates against the current generation.
KV key layout
| Key pattern | Value | TTL | Purpose |
|---|---|---|---|
otp:{email} | { otp, attempts } | 10 min | Pending OTP |
otp-gen-count:{email} | Count string | 10 min | Generation rate limit |
session:{uuid} | { email, gen } | 7 days | Active session |
session-gen:{email} | Generation number | None | Session invalidation counter |
Route guards
Protected paths
Routes requiring authentication are listed in hooks.server.ts:
const protectedPaths = ['/photos', '/polls', '/profile', '/documents', '/rsvp'];If the user has no session, they're redirected to /auth?redirect={originalPath}.
Admin guard
Admin routes (/admin/*) require both a valid session and the user's email in the event's adminEmails list:
const mergedConfig = await getMergedConfig(env.db, resolvedEvent.id);
if (!mergedConfig.adminEmails.includes(normalizeEmail(session.email))) {
throw redirect(302, '/');
}The adminEmails list is built from:
- The bootstrap admin email from
platform_events.adminEmail - Additional admin emails in the
settings.adminEmailsJSON array
Platform operator guard
Platform routes (/platform/*, /api/platform/*) use getOperatorContext():
const ctx = await getOperatorContext(env.db, session.email, env.PLATFORM_OPERATOR_EMAILS);This checks:
- Is the email in the
PLATFORM_OPERATOR_EMAILSenv var? →super_admin - Is the email in
platform_userswith an active status? → Role fromplatform_roles - Neither → access denied
Operator roles:
super_admin: Full platform access (create/manage any event or organization)admin: Organization-scoped access (manage events within assigned organizations)
Access request flow
Users not on the allowed list can request access at /request-access:
1. User submits email + name at /request-access
2. Record created in access_requests (status: 'pending')
3. Organizer reviews in /admin/people
4. Organizer approves → status set to 'approved', email added to allowedEmails
5. User can now sign inLocalhost dev bypass
For local development without Mailgun credentials:
function isLocalDev(platform, hostname): boolean {
return (
!platform?.env && // No Cloudflare bindings
hostname === 'localhost' && // Localhost only
process.env.NODE_ENV !== 'production' && // Not production
process.env.ENABLE_DEV_BYPASS === 'true' // Explicit opt-in
);
}When active, the dev bypass:
- Sets session to
{ email: 'dev@localhost' }without OTP - Grants
super_adminon platform routes - Skips admin email checks
Related pages
- Architecture — request lifecycle and hooks flow
- Database — access_requests, settings, and platform tables
- Environment Variables —
PLATFORM_OPERATOR_EMAILSand other auth-related config