Developer Docs

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:

  1. Visitor completes primary auth (OAuth, Magic Link, OTP).
  2. Auth service detects totp_enabled = true.
  3. Frontend prompts for the 6-digit TOTP code.
  4. Code is verified against the stored secret using the totp-rs crate.

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; Secure cookies (third-party cookie restrictions apply)
  • webauthn-rs allow_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.