Building a Zero-Trust Portal with Passkeys and Cloudflare Workers
The ARKONA ecosystem runs 57 services across 8 domains on an encrypted internal network. Until this week, the only way for an external stakeholder to see any of it was to send an email. The contact form on the portfolio site literally opened a mailto: link. No server-side processing, no submission tracking, no audit trail. For a platform that preaches AI governance and structured delegation, this was embarrassing.
The problem was straightforward: build an external access portal that is passwordless, phishing-resistant, scoped to specific domains, fully revocable by the admin, and deployed without spinning up a traditional backend server. The solution landed on three layers: Cloudflare Access for admin routes, WebAuthn passkeys for external users, and Cloudflare Workers + D1 for everything in between.
This article documents the architecture, the security decisions, and the specific traps encountered when implementing WebAuthn server-side verification on an edge runtime.
The Threat Model
Before writing code, the threat model needed to be explicit. The ARKONA ecosystem is a private research platform. External access is a privilege, not a right. The adversary model includes:
- Credential stuffing: Traditional passwords are out. Period. The attack surface of a password database is unacceptable for a single-operator platform.
- Phishing: Even if an attacker intercepts a session, the authentication mechanism itself must not be replayable. WebAuthn's challenge-response model addresses this directly — the credential is bound to the origin.
- Stolen invite codes: If an invite code leaks, the attacker must not be able to register a passkey for an email they don't control. This requires an email verification step before registration.
- Session hijacking via XSS: If a script injection reaches the portal page, session tokens must be invisible to JavaScript. HttpOnly cookies with SameSite=Strict are the only acceptable session storage.
- Scope creep: An external user granted access to COMET must not be able to see FORGE. Access is scoped per-domain, tied to the original invite code, and re-validated on every authenticated request.
- Admin loss of control: At any point, the admin must be able to revoke a single session, all sessions for a user, or all sessions globally. Revocation must be instant, not dependent on token expiry.
Architecture: Three Layers of Auth
The final system has three distinct authentication layers, each protecting a different surface:
Layer 1: Cloudflare Access (Admin Routes)
Cloudflare Access sits at the edge, before any request hits the Worker. It protects /api/invite/* (admin endpoints) and can optionally gate the tunnel subdomains (comet.arkonaresearch.com, etc.). The admin authenticates via Google OAuth or email OTP — no code required, purely a dashboard configuration.
This layer is the kill switch. If something goes wrong with the passkey system, Cloudflare Access is an independent gate that doesn't share any code or state with the Worker.
Layer 2: WebAuthn Passkeys (External Users)
External stakeholders authenticate with passkeys — Face ID on iPhone, Touch ID on Mac, Windows Hello on PC. No passwords exist in the system. The registration flow has three steps, each with its own security gate:
- Email verification: The user submits their email and invite code. The server sends a 6-digit OTP via Mailchannels. The OTP is stored in Cloudflare KV with a 10-minute TTL and rate-limited to 5 attempts per email per hour. The response is deliberately uniform — "If the invite is valid, a code was sent" — to prevent invite code enumeration.
- OTP validation: The user enters the 6-digit code. Comparison is constant-time (XOR accumulator) to prevent timing side-channels. On success, a registration token is issued with a 15-minute TTL.
- Passkey creation: The browser's WebAuthn API creates a credential. The attestation object is sent to the Worker, which performs full server-side verification: CBOR decoding of the attestation, RP ID hash comparison, user presence and verification flag checks, COSE public key extraction, and SPKI export for storage.
Subsequent logins are simpler: email → challenge → biometric → assertion verification → session cookie.
Layer 3: Cloudflare Worker + D1 (Session and Scope Management)
The Worker runs on Cloudflare's edge network. It handles the contact form, passkey registration/authentication, invite code management, and portal API. State is stored in two places:
- D1 (SQLite at the edge): Persistent storage for contacts, invite codes, passkey users, credentials, and sessions. Six tables with proper foreign keys and indexes.
- KV: Ephemeral storage for challenges, OTPs, and rate-limit counters. Everything in KV has a TTL — no manual cleanup required.
The Hard Part: WebAuthn on an Edge Runtime
The WebAuthn specification is elegant in theory and brutal in practice. The browser side is well-documented — navigator.credentials.create() and navigator.credentials.get() handle the heavy lifting. The server side, however, requires parsing binary formats that most web developers never encounter.
CBOR Decoding
The attestation object is CBOR-encoded (Concise Binary Object Representation, RFC 8949). Cloudflare Workers don't have a built-in CBOR parser, and importing a full npm package felt wrong for what is fundamentally a handful of data types. The implementation is a minimal decoder that handles the subset WebAuthn uses: maps, byte strings, text strings, unsigned integers, negative integers, arrays, and simple values.
The critical nuance is that CBOR maps can use integer keys (negative integers, specifically). COSE public keys use key labels like 1 (kty), 3 (alg), -1 (crv or n), -2 (x or e), -3 (y). A naive JSON-based parser would choke on negative integer keys. The decoder uses JavaScript Map objects to preserve key types.
Authenticator Data Parsing
The authenticator data is a packed binary structure, not CBOR. It starts with 32 bytes of RP ID hash, 1 byte of flags, 4 bytes of counter (big-endian), then — if the AT flag (bit 6) is set — 16 bytes of AAGUID, 2 bytes of credential ID length (big-endian), the credential ID, and finally a CBOR-encoded COSE public key. Parsing this requires byte-level offset tracking.
The flags byte is where the security checks happen:
Bit 0 (0x01): User Presence (UP) — was the user physically present?
Bit 2 (0x04): User Verification (UV) — was biometric/PIN verified?
Bit 6 (0x40): Attested Credential Data (AT) — is a new credential included?
Both UP and UV must be set. If either is missing, the registration or authentication is rejected. This is non-negotiable — it's the difference between "someone pressed a button" and "someone proved they are who they claim to be."
COSE Key to Web Crypto
COSE (CBOR Object Signing and Encryption) keys use a different format than what the Web Crypto API expects. For ES256 (ECDSA with P-256), the COSE key contains raw x and y coordinates as byte strings. These need to be base64url-encoded and wrapped in a JWK (JSON Web Key) for crypto.subtle.importKey(). The key is then exported as SPKI (Subject Public Key Info) for compact storage in D1.
For RS256 (RSASSA-PKCS1-v1_5 with SHA-256), the same process applies but with n (modulus) and e (exponent) parameters.
Assertion Signature Verification
During authentication, the authenticator signs a concatenation of the authenticator data and the SHA-256 hash of the client data JSON. The signature format depends on the algorithm:
- ES256: The authenticator produces a DER-encoded ECDSA signature, but the Web Crypto API's
verify()expects rawr || s(64 bytes for P-256). A DER-to-raw conversion is required: parse the two INTEGER sequences, strip leading zero bytes, and pad each to 32 bytes. - RS256: The signature can be passed directly — no conversion needed.
The counter check is also critical. Each authenticator maintains a monotonically increasing counter. If the assertion counter is not strictly greater than the stored counter, it indicates a possible cloned authenticator — the credential may have been extracted and replayed from a different device. The system rejects the authentication and logs the anomaly.
Session Security: Why Cookies Beat localStorage
The initial implementation stored session tokens in localStorage. This is the default pattern in most SPA tutorials, and it's wrong for any system with meaningful security requirements.
localStorage is accessible to any JavaScript running on the page. A single XSS vulnerability — a compromised CDN, a reflected injection, a malicious browser extension — exposes every token. The token can be exfiltrated to a remote server and replayed from anywhere.
HttpOnly cookies are invisible to JavaScript. They're sent automatically by the browser on same-origin requests, and the SameSite=Strict attribute prevents them from being sent on cross-origin requests (CSRF mitigation). The Secure flag ensures they're only transmitted over HTTPS. The Path=/api restriction limits the cookie to API endpoints — it won't be included in requests for static assets.
The trade-off is that the frontend can't inspect the session token to check if it's expired. Instead, the portal page makes a lightweight fetch('/api/portal/me') on load. If the response is 401, the auth gate is shown. This adds one HTTP round-trip, but the alternative — trusting client-side state — is not acceptable.
Revocation: The Admin Kill Switch
Revocation was designed as a first-class concern, not an afterthought. The system supports four levels of revocation:
- Session logout: The user clicks "Sign Out." The Worker sets
revoked = 1on their session row and clears the cookie. - Admin kill-session:
POST /api/invite/kill-sessionwith an email. All active sessions for that user are revoked immediately. - Invite revocation:
POST /api/invite/revokewith an invite code. The invite is deleted, and all sessions for users who registered with that invite are cascaded — revoked in the same transaction. - Global kill:
POST /api/invite/kill-all. Every active session in the system is revoked. This is the panic button.
Additionally, every authenticated API request re-checks the invite code's validity. If an admin revokes an invite between two API calls, the next request from that user will fail with "Access has been revoked" — even if their session cookie is technically still valid. This is belt-and-suspenders: the session check catches most cases, but the invite re-validation catches edge cases where the session table update might be eventually consistent.
What External Users Actually See
The portal offers two view modes, toggleable in the dashboard:
Demo Snapshots (Option A — Default)
A headless Chromium script (capture-demos.sh) takes full-page screenshots of each running ecosystem service. These are served as static HTML pages with the screenshot embedded as an image. External users see a frozen-in-time view of the interface — no live data, no interactivity, no attack surface.
This is the safe default. It answers the question "what does COMET look like?" without exposing a single API endpoint or database query.
Live Access (Option B — Cloudflare Tunnel)
For trusted stakeholders (hiring managers reviewing the platform, collaborators on active projects), each ecosystem domain can be exposed via Cloudflare Tunnel at a dedicated subdomain: comet.arkonaresearch.com, forge.arkonaresearch.com, etc. Each tunnel is independently gated by Cloudflare Access policies.
The tunnel configuration maps each subdomain to a localhost port. The cloudflared daemon runs on the server and maintains persistent outbound connections to Cloudflare's edge — no inbound ports need to be opened, no public IP is required. Traffic flows: user → Cloudflare edge (Access check) → tunnel → localhost service.
Before enabling live access for any domain, that application must implement a read-only guest mode. The tunnel provides transport-level access control, but application-level authorization is what prevents a guest from modifying data. This is the remaining work item.
Rate Limiting Without a Rate Limiter
Cloudflare Workers don't have a built-in rate limiting primitive (that's a separate paid product). The implementation uses KV with TTL as a counting mechanism:
// Rate limit: max 10 auth attempts per IP per hour
const ip = request.headers.get('CF-Connecting-IP');
const rateKey = `auth_rate:${ip}`;
const count = parseInt(await env.SESSIONS.get(rateKey) || '0');
if (count >= 10) {
return corsResponse({ error: 'Too many attempts.' }, 429);
}
await env.SESSIONS.put(rateKey, String(count + 1), { expirationTtl: 3600 });
Each KV key auto-expires after the window. No cleanup jobs, no counter resets, no state management. The trade-off is that KV is eventually consistent — a distributed attacker could theoretically exceed the limit by a few requests before the counter propagates across Cloudflare's edge locations. For a private portal with single-digit concurrent users, this is acceptable.
Two separate rate limits are enforced: 10 authentication attempts per IP per hour (prevents credential stuffing), and 5 OTP sends per email per hour (prevents email bombing). The email rate limit is keyed on the email address, not the IP, because a legitimate user might retry from different networks.
Anti-Enumeration: Uniform Responses
A common vulnerability in authentication systems is information leakage through response differentiation. If /api/passkey/auth/begin returns 404 for unknown emails but 200 for known emails, an attacker can enumerate valid accounts by observing the status code.
The ARKONA portal returns the same structure for both cases:
// Unknown email: return valid-looking options with empty allowCredentials
// The browser will prompt for a passkey but find none — fails gracefully
// An attacker observing the network sees identical response shapes
return corsResponse({
options: {
challenge: challenge,
rpId: env.RP_ID,
allowCredentials: [], // Empty, but structurally identical
userVerification: 'required',
timeout: 60000,
},
});
The same principle applies to the email verification endpoint during registration. The response is always "If the invite is valid, a code was sent to your email" — regardless of whether the invite code exists, is expired, or the email doesn't match. The attacker learns nothing from the response.
The Stack
The entire auth system runs on Cloudflare's free tier with zero traditional servers:
| Component | Service | Cost |
|---|---|---|
| Static site | Cloudflare Pages | Free |
| API / auth logic | Cloudflare Workers | Free (100K req/day) |
| Persistent storage | Cloudflare D1 | Free (5M rows reads/day) |
| Ephemeral storage | Cloudflare KV | Free (100K reads/day) |
| Admin auth | Cloudflare Access | Free (50 users) |
| Tunnel | Cloudflare Tunnel | Free |
| Email OTP | Mailchannels (via Workers) | Free |
Total monthly cost for a fully authenticated, passkey-secured, edge-deployed portal with admin controls and live tunneling: $0.
What's Next
Three items remain before the portal is production-ready:
- Read-only guest mode in each app: Each ecosystem service needs a
?guest=truemode that hides write operations, admin panels, and sensitive configuration. This is application-level work, not infrastructure. - Cloudflare Access policy deployment: The tunnel subdomains need Access Applications configured in the Zero Trust dashboard. Each subdomain gets its own policy, allowing only portal users with matching scopes.
- Automated demo capture cron: The
capture-demos.shscript should run nightly to keep snapshots fresh. A cron entry and a deploy trigger will keep the static demos current without manual intervention.
The foundation is solid: passwordless, phishing-resistant, admin-revocable, edge-deployed, zero-cost. The portal transforms ARKONA from a system that can only be described to one that can be experienced — on terms that the operator fully controls.