Skip to content

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 parsing

Two separate auth contexts exist:

  1. Event auth: Email OTP for attendees and organizers accessing event sites
  2. Platform auth: Same OTP sign-in, but gated by operator role checks for /platform/* routes

OTP flow

Generation

typescript
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:

typescript
await kv.put(`otp:${email}`, JSON.stringify({ otp, attempts: 0 }), {
  expirationTtl: 600  // 10 minutes
});

Rate limiting

Two rate limits protect against abuse:

LimitScopeThresholdWindow
GenerationPer email5 OTPs10 minutes
VerificationPer OTP3 attemptsUntil OTP expires

Generation rate limiting uses a counter in KV:

typescript
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:

typescript
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:

  1. Read stored OTP from KV
  2. If missing → expired (return { expired: true })
  3. If attempts >= 3 → too many attempts (delete OTP, return { tooManyAttempts: true })
  4. Increment attempts counter before comparison (closes race window)
  5. Compare using timing-safe equality
  6. If match → delete OTP, return { valid: true }
  7. If no match → return { valid: false } (attempts already incremented)

Sessions

Creation

After successful OTP verification, a session is created:

typescript
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:

typescript
if (sessionId) {
  event.locals.session = await getSession(env.kv, sessionId);
}

getSession() checks both existence and generation:

typescript
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:

typescript
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 patternValueTTLPurpose
otp:{email}{ otp, attempts }10 minPending OTP
otp-gen-count:{email}Count string10 minGeneration rate limit
session:{uuid}{ email, gen }7 daysActive session
session-gen:{email}Generation numberNoneSession invalidation counter

Route guards

Protected paths

Routes requiring authentication are listed in hooks.server.ts:

typescript
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:

typescript
const mergedConfig = await getMergedConfig(env.db, resolvedEvent.id);
if (!mergedConfig.adminEmails.includes(normalizeEmail(session.email))) {
  throw redirect(302, '/');
}

The adminEmails list is built from:

  1. The bootstrap admin email from platform_events.adminEmail
  2. Additional admin emails in the settings.adminEmails JSON array

Platform operator guard

Platform routes (/platform/*, /api/platform/*) use getOperatorContext():

typescript
const ctx = await getOperatorContext(env.db, session.email, env.PLATFORM_OPERATOR_EMAILS);

This checks:

  1. Is the email in the PLATFORM_OPERATOR_EMAILS env var? → super_admin
  2. Is the email in platform_users with an active status? → Role from platform_roles
  3. 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 in

Localhost dev bypass

For local development without Mailgun credentials:

typescript
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_admin on platform routes
  • Skips admin email checks

Released under the MIT License.