🏗️ 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:
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.
| Endpoint | What it does |
|---|---|
| GET /.well-known/openid-configuration | Discovery document (issuer, endpoints, grants, S256, ES256). |
| GET /authorize | Session-gated consent screen; validates client + exact redirect first. |
| POST /oidc/token | Form-encoded, client-authenticated code exchange → opaque access token + ES256 ID token. |
| GET /oidc/userinfo | Bearer-authed scoped claims (sub, email). |
| GET /.well-known/jwks.json | The public signing key for ID-token verification. |
| POST /oidc/par | RFC 9126 — push the request back-channel; the redirect carries only client_id + request_uri. |
| POST /oidc/revoke | RFC 7009 token revocation. |
| POST /oidc/introspect | RFC 7662 token introspection. |
| POST /oidc/device_authorization | RFC 8628 device-code flow, for input-constrained clients. |
| GET /oidc/logout | RP-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_challengewith methodS256; the token endpoint recomputesbase64url(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 byclient_idonly. It's safe because the authorization code it must present is bound to acode_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_secretand stores an empty secret hash. The confidential path is unchanged: a confidential client with no secret still failsinvalid_client. - Public clients can't be machines. Without a secret there's nothing to authenticate a user-less
client_credentialscall, 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
- Do the practicals →
- Public JWK set (/.well-known/jwks.json) →
- security.txt →
- The IntegrAuth Academy →
- Talk to IntegrAuth about your project →
The source will be published as a public GitHub repository at ship time.