L IntegrAuth Lab

🏗️ How the Lab is built

IntegrAuth Lab is one Cloudflare Worker: a SvelteKit UI and a Hono API on the same edge runtime, backed by D1 (SQLite), with rate-limit counters in a Durable Object. Cryptography runs on standard, Workers-compatible libraries — jose, otpauth and @simplewebauthn/server — with AES-GCM at rest on WebCrypto directly. This page documents the decisions and their trade-offs, the way we document the systems we build for clients.

Architecture

Requests hit a wrapper worker that applies security headers, then SvelteKit renders pages while /api/* is handed to Hono. State lives in D1: users, credentials (public keys only), sessions (hashed tokens), single-use challenges, TOTP ciphertext and the insert-only audit log. Rate-limit counters are the one thing that does not: they live in a Durable Object, because a counter needs a strongly-consistent read-modify-write. Here is the passkey login flow end to end:

BrowserWorkerD1POST /login/optionsINSERT challenge (single-use){ challenge, rpId, UV required }navigator.credentials.get()device signs the challengePOST /login/verify (assertion)consume challenge (replay guard)verify signature (WebCrypto)origin · rpIdHash · signCountINSERT session (SHA-256) + auditSet-Cookie __Host-lab_session
Passkey login: a single-use challenge, a device-held private key, server-side signature verification, and a hashed-at-rest session — with an audit event at each step.

Key decisions & trade-offs

Passwordless only — no password column exists

Signup proves the mailbox with a one-time code; login is a passkey (WebAuthn). Trade-off: a passkey lives on a device, so recovery is an email-code re-verification that lets you enroll a new one. There is simply no password to phish, reuse or leak.

Everything hashed / encrypted at rest

Email codes and session tokens are stored as SHA-256; TOTP seeds as AES-GCM ciphertext under a Wrangler secret. A database read never yields a usable credential. Trade-off: we can never "show" a code — only verify one.

Insert-only audit log as the X-ray source

Every flow appends one sanitized event (a key-name denylist strips anything secret-ish). There is no delete path at all, and exactly one sanctioned update: erasing an account (RTBF) rewrites the erased person's user_id to a one-way pseudonym and strips PII from the detail. It cannot touch id, event or created_at, and the row count never changes — so the hash chain over those immutable fields, and the tamper-evidence it gives, survive the one exception. Trade-off: detail is deliberately coarse (counters, claim names, cookie names) — never values.

run_worker_first for one header chokepoint

A thin src/worker.ts wraps the SvelteKit worker and stamps the strict security-header set on every response — pages, JSON and static assets alike — so HSTS, nosniff, frame-ancestors none and the CSP can never be missed. SvelteKit still owns the per-page CSP that carries its hydration-script hashes.

Free-tier caps, on purpose

Workers/D1/Turnstile free tiers. Email shares one Resend account with another app, so the Lab is a light consumer: per-email and per-IP caps through the rate limiter (a Durable Object), plus a hard global daily cap in D1. When the global cap is hit we return a friendly 429 rather than queue.

Standard crypto libraries, not reinvention

The crypto that matters runs on well-maintained, Workers-compatible libraries — jose (ES256 JWS/JWT + JWKS), otpauth (TOTP), @simplewebauthn/server (WebAuthn/FIDO2) — because the Lab showcases IAM engineering, not low-level crypto reinvention. AES-GCM at rest uses WebCrypto (crypto.subtle) directly. The app was first built fully hand-rolled and the core has since migrated onto these libraries, each verified to bundle for workerd with npm run dryrun.

OIDC provider

The Lab is its own OpenID Connect Authorization Server, written from scratch on the same Worker (on jose for the ES256 signing) so students can register an app and sign users in for real (see the assignment). The path everything else is built on is authorization-code + PKCE (S256, the only challenge method accepted, on every single request). Four more grants are published beside it — refresh_token, client_credentials, device code and token exchange — so read /.well-known/openid-configuration, not this table, as the authority on what this AS supports.

EndpointWhat it does
GET /.well-known/openid-configurationDiscovery document (issuer, endpoints, grants, S256, ES256).
GET /authorizeSession-gated consent screen; validates client + exact redirect first.
POST /oidc/tokenForm-encoded, client-authenticated code exchange → opaque access token + ES256 ID token.
GET /oidc/userinfoBearer-authed scoped claims (sub, email).
GET /.well-known/jwks.jsonThe public signing key for ID-token verification.
POST /oidc/parRFC 9126 — push the request back-channel; the redirect carries only client_id + request_uri.
POST /oidc/revokeRFC 7009 token revocation.
POST /oidc/introspectRFC 7662 token introspection.
POST /oidc/device_authorizationRFC 8628 device-code flow, for input-constrained clients.
GET /oidc/logoutRP-Initiated Logout 1.0; fans a signed logout_token out over Back-Channel Logout 1.0.
  • Exact redirect-URI matching, validated first. The client and its registered redirect_uri are checked before any error can become a redirect — we never bounce a user to an unvalidated URI (RFC 6749 §4.1.2.1).
  • Hashed, one-time codes. Authorization codes are SHA-256-hashed at rest, live 60 seconds, and are consumed atomically (a conditional UPDATE) — the DB never holds a usable code.
  • PKCE-only, no plain. Every request must carry a code_challenge with method S256; the token endpoint recomputes base64url(SHA-256(verifier)) and compares in constant time.
  • Replay revocation. Re-presenting a consumed code fails and revokes every token that code minted (OAuth BCP), so a leaked-then-replayed code can’t leave a live token behind.
  • Protocol vs management split. /oidc/* lives outside /api/* so the CSRF Origin check never blocks a server-to-server token POST; the browser management routes (/api/oidc/*) keep both the session gate and the CSRF check.
  • Client secrets hashed; shown once. Registration returns the secret a single time and stores only its SHA-256, exactly like the rest of the Lab’s credentials.

Public clients & the live Demo RP

Not every app can keep a secret. A single-page app or mobile client ships its code to the user, so a confidential client_secret would be right there for anyone to read. The modern answer is a public client: no secret at all, identified by its client_id alone, proving each login with PKCE instead.

  • PKCE replaces the secret. A public client (token_endpoint_auth_method: none) authenticates by client_id only. It's safe because the authorization code it must present is bound to a code_challenge — only the browser that generated the verifier can redeem it — and delivered to an exact, pre-registered redirect URI.
  • No secret is ever minted. Registering a public client returns no client_secret and stores an empty secret hash. The confidential path is unchanged: a confidential client with no secret still fails invalid_client.
  • Public clients can't be machines. Without a secret there's nothing to authenticate a user-less client_credentials call, so that grant is rejected for public clients at registration and again at the token endpoint.
  • The Demo RP is a real public client. The Demo app runs client-side authorization-code + PKCE against this very OIDC provider, validates the ID token against JWKS in the browser, and calls userinfo — a reference SPA relying party. View source (src/routes/demo/*, src/lib/demo/rp.ts) to see how a secret-less login is built.

Honest limitations

  • The demo JWT proves signing/verification and key discovery — it is NOT an authorization token. Real access control needs audience-scoped tokens and a resource server that enforces them (see the OIDC provider, below).
  • Passkey registration defaults to attestation: "none" (the industry default), so an ordinary enrolment is anonymous and unverified. Your account page has an opt-in attested path that requests attestation: "direct" and cryptographically verifies the statement — but it only reports what it found. The Lab never restricts which authenticators you may use.
  • Sessions and challenges live in D1; rate-limit counters live in a Durable Object (SQLite-backed, on the Workers free tier) for strongly-consistent read-modify-write. No Queues: when the global email cap is hit the Lab returns a friendly 429 rather than queuing.

Explore

The source will be published as a public GitHub repository at ship time.