MFA & Passkeys โ Developer Integration Guide
Overview
The auth subgraph provides two complementary security layers beyond primary authentication:
- TOTP (Time-based One-Time Password): Authenticator appโbased second factor using the RFC 6238 standard.
- WebAuthn Passkeys: Phishing-resistant passwordless login using platform authenticators (Touch ID, Face ID, Windows Hello).
Both are implemented server-side in Rust and exposed via REST endpoints. The frontend communicates through the Vite proxy at /api/auth/.
Architecture
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
โ Browser/Client โโโโโโถโ Vite Proxy โโโโโโถโ Auth โ
โ (WebAuthn API) โ โ /api/auth โ :3008โ โ Service โ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโฌโโโโโ
โ
โโโโโโโโโโโโโโผโโโโโโโโโโโโโ
โ โ โ
โโโโโโผโโโโ โโโโโโผโโโโ โโโโโโผโโโโโ
โ Redis โ โ Postgresโ โwebauthn โ
โ(state) โ โ (creds)โ โ -rs โ
โโโโโโโโโโ โโโโโโโโโโ โโโโโโโโโโโ
TOTP (Two-Factor Authentication)
Enrollment Flow
Step 1 โ Start Enrollment (authenticated)
POST /auth/mfa/totp/enroll
Cookie: session_token=...
Response:
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/Mallnline:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Mallnline"
}
The otpauth_uri is rendered as a QR code on the frontend for the Visitor to scan with their authenticator app.
Step 2 โ Verify & Activate (authenticated)
POST /auth/mfa/totp/verify
Cookie: session_token=...
Content-Type: application/json
{
"code": "123456"
}
On success, the Visitor's totp_enabled flag is set to true in the database. All subsequent logins will require a TOTP code after primary authentication.
Disabling TOTP
POST /auth/mfa/totp/disable
Cookie: session_token=...
Content-Type: application/json
{
"code": "123456"
}
Requires a valid TOTP code to confirm the action. On success, totp_secret is cleared and totp_enabled is set to false.
MFA Challenge on Login
When 2FA is enabled, the login flow changes:
- Visitor completes primary auth (OAuth, Magic Link, OTP).
- Auth service detects
totp_enabled = true. - Frontend prompts for the 6-digit TOTP code.
- Code is verified against the stored secret using the
totp-rscrate.
Database Schema
ALTER TABLE users
ADD COLUMN totp_secret TEXT,
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
Migration: apps/auth/migrations/20260406000000_mfa_totp.sql
WebAuthn Passkeys
Passkeys use the Web Authentication API (WebAuthn Level 2) implemented via webauthn-rs v0.5.4.
Configuration
Environment variables in apps/auth/.env:
WEBAUTHN_RP_ID=localhost # Relying Party ID (domain)
WEBAUTHN_RP_NAME="Ngwenya Auth" # Display name in browser prompts
WEBAUTHN_ORIGIN=http://localhost:5173 # Must match browser origin exactly
IMPORTANT
WEBAUTHN_ORIGIN must match the browser origin (scheme + host + port), not the auth service port. In production this will be https://mallnline.com.
Registration Flow (Authenticated)
Step 1 โ Start Registration
POST /auth/passkey/register/start
Cookie: session_token=...
Content-Type: application/json
{}
Response:
{
"challenge": {
"publicKey": {
"rp": { "name": "Ngwenya Auth", "id": "localhost" },
"user": { "id": "base64url", "name": "username", "displayName": "..." },
"challenge": "base64url-encoded",
"pubKeyCredParams": [...],
"authenticatorSelection": { "residentKey": "discouraged", "userVerification": "required" }
}
},
"reg_id": "uuid"
}
The frontend must convert challenge and user.id from base64url strings to ArrayBuffer before passing to navigator.credentials.create().
Step 2 โ Browser Ceremony
// Convert base64url fields to ArrayBuffers
challenge.publicKey.challenge = base64urlToBuffer(challenge.publicKey.challenge);
challenge.publicKey.user.id = base64urlToBuffer(challenge.publicKey.user.id);
// Force platform authenticator (Touch ID / Face ID)
challenge.publicKey.authenticatorSelection.authenticatorAttachment = 'platform';
const credential = await navigator.credentials.create({ publicKey: challenge.publicKey });
WARNING
Do not override residentKey or userVerification from the server response โ these are set by webauthn-rs and overriding them causes NotSupportedError in Chrome.
Step 3 โ Finish Registration
POST /auth/passkey/register/finish
Cookie: session_token=...
Content-Type: application/json
{
"reg_id": "uuid-from-step-1",
"response": {
"id": "credential.id",
"rawId": "base64url(credential.rawId)",
"response": {
"attestationObject": "base64url(...)",
"clientDataJSON": "base64url(...)"
},
"type": "public-key"
}
}
Login Flow (Public)
Step 1 โ Start Login
POST /auth/passkey/login/start
Content-Type: application/json
{ "email": "user@example.com" }
Step 2 โ Browser Ceremony
challenge.publicKey.challenge = base64urlToBuffer(challenge.publicKey.challenge);
challenge.publicKey.allowCredentials.forEach((c) => {
c.id = base64urlToBuffer(c.id);
});
const credential = await navigator.credentials.get({ publicKey: challenge.publicKey });
Step 3 โ Finish Login
POST /auth/passkey/login/finish
Content-Type: application/json
{
"login_id": "uuid",
"response": {
"id": "...",
"rawId": "base64url(...)",
"response": {
"authenticatorData": "base64url(...)",
"clientDataJSON": "base64url(...)",
"signature": "base64url(...)",
"userHandle": "base64url(...)"
},
"type": "public-key"
}
}
On success, session and refresh cookies are set directly on the response.
Database Schema
CREATE TABLE passkeys (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
credential_id BYTEA NOT NULL,
public_key BYTEA,
sign_count INTEGER,
device_name TEXT,
passkey_data JSONB NOT NULL, -- serialized webauthn-rs Passkey struct
created_at TIMESTAMPTZ DEFAULT NOW()
);
Resolved Issues
NOTE
P0 Bug โ Origin Mismatch (Resolved 2026-04-05): The register/finish step was returning InvalidRPOrigin when the ceremony was initiated from the frontend (localhost:5173). Root cause: WEBAUTHN_ORIGIN was set to the auth service origin (http://localhost:3008) instead of the frontend origin (http://localhost:5173). The browser hardcodes clientDataJSON.origin = window.location.origin, and webauthn-rs performs a strict equality check. Fix: set WEBAUTHN_ORIGIN to the exact frontend origin. Secondary issue: make services was running a stale binary after the .env fix โ resolved by adding make restart-auth.
Production Deployment
Strategy: Same-Origin Reverse Proxy
In production, the frontend and auth service are served under a single domain via a reverse proxy (Nginx, Cloudflare, etc.). This eliminates cross-origin cookie issues and ensures the browser's WebAuthn origin claim always matches the server configuration.
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
โ Browser โโโโโโถโ Reverse Proxy โโโโโโถโ Auth โ
โ mallnline.com โ โ mallnline.com โ โ Service โ
โ (WebAuthn API) โ โ /api/auth/* โ :3008 โ โ (:3008) โ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
Environment Variables
| Variable | Development | Production |
|---|---|---|
WEBAUTHN_RP_ID |
localhost |
mallnline.com |
WEBAUTHN_RP_NAME |
"Ngwenya Auth" |
"Mallnline" |
WEBAUTHN_ORIGIN |
http://localhost:5173 |
https://mallnline.com |
Cookie secure |
false |
true |
Cookie same_site |
Lax |
Lax |
IMPORTANT
WEBAUTHN_RP_ID determines which domain the credential is bound to. Once a Visitor registers a passkey with rp_id=mallnline.com, that credential cannot be used on auth.mallnline.com or any other subdomain. Choose the RP ID carefully โ it is permanent for the lifetime of the credential.
WARNING
The cookie secure flag is currently hardcoded to false in routes/passkey.rs and routes/email.rs (// TODO: Set to true in production). This must be true in production (HTTPS only). Consider driving this from an environment variable like COOKIE_SECURE=true.
Reverse Proxy Configuration (Nginx Example)
server {
listen 443 ssl;
server_name mallnline.com;
# Frontend (SvelteKit SSR or static build)
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Auth service
location /api/auth/ {
proxy_pass http://auth:3008/auth/;
proxy_set_header Host $host; # CRITICAL: preserve Host header
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
# GraphQL gateway
location /api/graphql {
proxy_pass http://gateway:30000/graphql;
proxy_set_header Host $host;
}
}
CAUTION
The reverse proxy must preserve the Host header (proxy_set_header Host $host). If the auth service sees the internal hostname instead of mallnline.com, session cookies will be scoped incorrectly.
Why Not Subdomains?
A subdomain architecture (auth.mallnline.com) is documented as a P2 research item. It would require:
SameSite=None; Securecookies (third-party cookie restrictions apply)webauthn-rsallow_subdomains(true)configuration- CORS preflight on every auth request
- More complex cookie domain scoping
The same-origin proxy approach avoids all of these complexities and is the recommended strategy for launch.
Startup Validation
The auth service validates WebAuthn configuration at startup and will panic if misconfigured:
2026-04-05 INFO auth::db: WebAuthn config: rp_id=mallnline.com, raw_origin=https://mallnline.com, parsed_origin=https://mallnline.com/
2026-04-05 INFO auth::db: WebAuthn initialized โ allowed_origins: ["https://mallnline.com/"]
If WEBAUTHN_ORIGIN is empty, the service will refuse to start with a descriptive error message.
File Reference
| File | Purpose |
|---|---|
apps/auth/src/routes/passkey.rs |
Passkey REST route handlers |
apps/auth/src/routes/mfa.rs |
TOTP MFA route handlers |
apps/auth/src/services/passkey.rs |
WebAuthn ceremony logic + diagnostic tracing |
apps/auth/src/services/mfa.rs |
TOTP enrollment, verify, disable |
apps/auth/src/db/mod.rs |
init_webauthn() โ WebAuthn builder + startup validation |
apps/auth/.env |
WEBAUTHN_* environment variables |
Makefile |
make restart-auth โ force rebuild + restart auth service |
Utility Functions (Frontend)
The frontend provides two critical conversion utilities in src/lib/services/auth.ts:
// Convert base64url string โ ArrayBuffer (for WebAuthn API input)
function base64urlToBuffer(base64url: string): ArrayBuffer;
// Convert ArrayBuffer โ base64url string (for JSON serialization)
function bufferToBase64url(buffer: ArrayBuffer): string;
These handle the impedance mismatch between the server's JSON-serialized base64url strings and the WebAuthn API's requirement for ArrayBuffer types.
Related
- Privacy & Security APIs โ Cookie consent, data export, session management, and security event log
- Corporate Identity (SAML & SCIM) โ Enterprise SAML SSO that complements MFA โ corporate users authenticate via their IdP instead of passkeys/TOTP
- Settings Architecture โ Frontend settings page where users manage MFA enrollment and passkeys
- Debugging & Testing โ Auth pipeline curl workflows for testing token and session flows