2FA & Passkeys
2FA & Passkeys
Two-factor authentication, passkeys, biometrics, and recovery codes — the complete second-factor strategy for core.auth.
Design Philosophy
core.auth treats 2FA as a spectrum, not a binary. Users can enable progressively stronger factors based on their needs:
| Level | Factor | What It Is | Who Uses It |
| 0 | Password only | Single factor | Users who haven't set up 2FA |
| 1 | TOTP | Time-based code from an app | Most users, easy to set up |
| 2 | Passkey / Biometric | Face ID, Touch ID, fingerprint, Windows Hello | Users with modern devices |
| 3 | Security key | Physical USB/NFC key (YubiKey, etc.) | High-security users, admins |
The system is designed so that biometrics are the path of least resistance — easier than typing a TOTP code, and more secure. Most users will never need to buy a hardware key; their phone's fingerprint reader or face scanner is enough.
TOTP (Time-based One-Time Password)
The classic 2FA method. User scans a QR code with an authenticator app (Google Authenticator, Authy, 1Password, etc.), then enters a 6-digit code that changes every 30 seconds.
Setup Flow
User enables 2FA in account settings
│
▼
core.auth generates a random secret (160 bits, base32-encoded)
│
▼
core.auth encrypts the secret (AES-256) and stores it in users.two_fa_secret
│
▼
core.auth returns:
├── QR code (otpauth:// URI)
├── Manual entry key (base32, grouped in 4s)
└── Recovery codes (generated at the same time)
│
▼
User scans QR code with authenticator app
│
▼
User enters a code to verify setup
│
▼
core.auth verifies the code against the encrypted secret
│
├── Valid → 2FA enabled, return success
└── Invalid → ask again (up to 3 attempts)Verification Flow
User enters password at login
│
▼
core.auth validates password → correct
│
▼
core.auth checks: user.two_fa_enabled?
│
├── No → issue JWT, done
│
└── Yes → return 403 { "error": "2fa_required", "challenge_id": "***" }
│
▼
Frontend shows TOTP input
│
▼
User enters 6-digit code
│
▼
POST /api/verify-2fa { "challenge_id": "***", "code": "123456" }
│
▼
core.auth:
├── Decrypt stored secret
├── Check current window (±1 window = 90 seconds tolerance)
├── Verify HMAC of code against secret
│
├── Valid → issue JWT + session_ref cookie
└── Invalid → 401, up to 3 attempts before lockoutTime Window Tolerance
TOTP codes are valid for 30 seconds. core.auth checks the current window plus ±1 window (90 seconds total) to account for clock drift between the server and the user's device.
function verifyTotp(string $secret, string $code, int $tolerance = 1): bool
{
$time = floor(time() / 30);
for ($i = -$tolerance; $i <= $tolerance; $i++) {
$expected = generateHotp($secret, $time + $i);
if (hash_equals($expected, $code)) {
return true;
}
}
return false;
}WebAuthn / Passkeys
WebAuthn is the modern standard for passwordless and second-factor authentication. It uses public-key cryptography — the private key never leaves the user's device.
core.auth supports WebAuthn as both a second factor (password + biometric) and as a primary factor (passkey-only, no password).
What Counts as a Passkey
| Type | Examples | How It Works |
| Platform authenticator | Face ID, Touch ID, Android biometrics, Windows Hello | Built into the device. User proves identity with face/fingerprint/PIN. |
| Cross-platform authenticator | YubiKey, Nitrokey, Google Titan | Physical USB/NFC/Bluetooth key. User touches the key or taps NFC. |
| Synced passkey | iCloud Keychain, Google Password Manager, 1Password | Passkey syncs across devices via the OS or password manager. User can use any synced device. |
Registration Flow (Adding a Passkey)
User clicks "Add passkey" in account settings
│
▼
Frontend calls: POST /api/webauthn/register
│
▼
core.auth generates a challenge (random bytes, 32 bytes)
Stores challenge in session (expires in 5 min)
Returns PublicKeyCredentialCreationOptions:
├── challenge
├── rp: { name: "UNVRSL", id: "medkronos.com" }
├── user: { id: user_uuid, name: email, displayName: name }
├── pubKeyCredParams: [{ alg: -7, type: "public-key" }, { alg: -257, type: "public-key" }]
├── authenticatorSelection: { authenticatorAttachment: "platform", userVerification: "required" }
└── timeout: 300000
│
▼
Browser shows native prompt: "Use Face ID / Touch ID / fingerprint?"
│
▼
User authenticates with biometric
│
▼
Browser/device generates keypair:
├── Private key: stored in secure enclave (TPM, Secure Enclave, TEE)
└── Public key: sent to core.auth
│
▼
Frontend sends attestation to: POST /api/webauthn/register/verify
│
▼
core.auth:
├── Verifies challenge matches
├── Verifies attestation signature
├── Extracts public key and credential ID
├── Stores in webauthn_credentials table
└── Returns successAuthentication Flow (Using a Passkey)
User enters email at login
│
▼
core.auth checks: user has passkeys?
│
├── Yes → return PublicKeyCredentialRequestOptions
│ ├── challenge
│ ├── rpId: "medkronos.com"
│ ├── allowCredentials: [{ id: credential_id, type: "public-key" }]
│ └── userVerification: "required"
│
└── No → fall back to password flow
│
▼
Browser shows: "Use Face ID / fingerprint?"
│
▼
User authenticates with biometric
│
▼
Device signs the challenge with private key
│
▼
Frontend sends assertion to: POST /api/webauthn/authenticate
│
▼
core.auth:
├── Looks up credential by credential_id
├── Verifies signature against stored public key
├── Verifies challenge matches
├── Updates sign_count (clone detection)
│
├── Valid → issue JWT + session_ref cookie
└── Invalid → 401Passkey-Only (No Password)
Users who have set up a passkey can skip the password entirely:
User enters email at login
│
▼
core.auth checks: user has passkeys + passkey_login_enabled?
│
└── Yes → show "Use passkey" button (prominent)
"Use password instead" link (secondary)
│
▼
User clicks "Use passkey" → WebAuthn flow (same as above)
│
▼
Passkey verified → issue JWT, no password neededThis is the path of least resistance for returning users: tap fingerprint, done. No password to remember, no TOTP code to type.
Biometrics as the Default 2FA
core.auth encourages biometrics over TOTP for most users:
| TOTP | Passkey / Biometric |
| Setup | Scan QR code, install app | Tap fingerprint, done |
| Login experience | Type 6-digit code | Tap fingerprint or glance at camera |
| Phishing resistant | No (user can be tricked into sharing code) | Yes (bound to origin, can't be phished) |
| Device dependency | Authenticator app on phone | Built into device OS |
| Sync | Manual backup of secret | Automatic via iCloud/Google/1Password |
| Security | Shared secret stored on server | Private key never leaves device |
Passkeys are strictly better than TOTP on every axis. core.auth should nudge users toward passkeys during setup and make TOTP the fallback for users without compatible devices.
Nudge Strategy
User enables 2FA
│
▼
core.auth detects: device supports WebAuthn?
│
├── Yes → "Set up passkey (recommended)" — prominent CTA
│ "Use authenticator app instead" — secondary link
│
└── No → "Set up authenticator app" — only option
"Your device doesn't support passkeys" — info messageRecovery Codes
When a user enables 2FA, core.auth generates 10 single-use recovery codes. These are the fallback when the user loses their 2FA device.
Generation
function generateRecoveryCodes(int $count = 10): array
{
$codes = [];
for ($i = 0; $i < $count; $i++) {
// 8-character alphanumeric, grouped: XXXX-XXXX
$bytes = random_bytes(5);
$code = strtoupper(bin2hex($bytes));
$codes[] = substr($code, 0, 4) . '-' . substr($code, 4, 4);
}
return $codes;
}Storage
Recovery codes are hashed (bcrypt) before storage — like passwords. core.auth stores only the hashes, not the plaintext codes. This means even if the database is compromised, the recovery codes can't be extracted.
-- Recovery codes stored in a separate table
CREATE TABLE recovery_codes (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
code_hash VARCHAR(255) NOT NULL, -- bcrypt hash
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);Usage Flow
User loses 2FA device
│
▼
User enters email + password at login
│
▼
core.auth: "2FA required. Enter code or use recovery code."
│
▼
User clicks "Use recovery code"
│
▼
User enters recovery code: ABCD-EFGH
│
▼
core.auth:
├── Hash the entered code
├── SELECT * FROM recovery_codes WHERE user_id = ? AND used_at IS NULL
├── Compare hash against all unused codes (bcrypt)
│
├── Match found:
│ ├── Mark code as used (SET used_at = NOW())
│ ├── Issue JWT + session
│ ├── Notify user: "Recovery code used. X remaining."
│ └── If remaining < 3: prompt to regenerate
│
└── No match → 401, up to 3 attemptsRegeneration
After using recovery codes, users should regenerate new ones. core.auth prompts this when:
- Fewer than 3 recovery codes remain
- User explicitly requests regeneration in account settings
Regeneration invalidates all unused old codes and generates 10 new ones.
2FA Enforcement Policies
Per-App Enforcement
// registered_apps configuration
{
"require_2fa": false, // app doesn't require 2FA
"require_2fa_for_admins": true // but admins must have 2FA
}An app can require 2FA for all users or just for admin roles. If a user without 2FA tries to access a 2FA-required app, they're redirected to the 2FA setup flow.
Per-Scope Enforcement
Critical scopes always require 2FA re-verification, regardless of app settings. This is handled by the critical action token flow (see Scope Model):
User attempts critical-scope operation (e.g., password.change)
│
▼
App redirects to core.auth/verify-2fa?scope=password.change
│
▼
User completes 2FA challenge (any enabled method)
│
▼
core.auth issues critical_action_token (2 min TTL, single-use)
│
▼
App includes critical_action_token in the password change request
│
▼
core.auth validates both JWT + critical_action_token → proceedGrace Period
After initial login with 2FA, there's a grace period before critical scopes require re-verification. Default: 15 minutes. This prevents the annoying experience of logging in with 2FA and immediately being asked for 2FA again.
function needsReverification(string $userId, string $scope): bool
{
$last2fa = getLast2faVerification($userId);
$gracePeriod = 15 * 60; // 15 minutes
if (time() - $last2fa < $gracePeriod) {
return false; // Still in grace period
}
return isCriticalScope($scope);
}Database Schema
webauthn_credentials
CREATE TABLE webauthn_credentials (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
credential_id VARCHAR(512) UNIQUE NOT NULL, -- base64url
public_key TEXT NOT NULL, -- COSE-encoded public key
sign_count BIGINT DEFAULT 0, -- clone detection
aaguid VARCHAR(36), -- authenticator identifier
nickname VARCHAR(100), -- "My iPhone", "Work laptop"
transports JSON, -- ["internal", "usb", "nfc"]
attestation_format VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP
);
CREATE INDEX idx_webauthn_user ON webauthn_credentials(user_id);
CREATE INDEX idx_webauthn_credential ON webauthn_credentials(credential_id);recovery_codes
CREATE TABLE recovery_codes (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
code_hash VARCHAR(255) NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_recovery_user ON recovery_codes(user_id);
CREATE INDEX idx_recovery_unused ON recovery_codes(user_id) WHERE used_at IS NULL;Mobile Device as 2FA (Future)
Mobile devices can serve as 2FA through push notifications — similar to Google Prompt or Microsoft Authenticator's push flow:
User enters password
│
▼
core.auth sends push notification to registered mobile device
│
▼
User sees: "Login attempt from Chrome on Windows. Approve?"
│
├── User taps "Approve" → JWT issued
└── User taps "Deny" → session blocked, security event loggedThis is essentially a biometric check on the phone (Face ID / fingerprint to approve the prompt) wrapped in a push notification. It's faster than TOTP and more phishing-resistant.
This requires a mobile app or PWA with push notification support and a registered device token. Planned as a future extension.
Login Flow Summary
User visits app → no JWT
│
▼
Redirect to core.auth/login
│
▼
User enters email
│
▼
core.auth checks: has passkeys?
│
├── Yes → offer "Use passkey" (prominent) or "Use password" (secondary)
│ │
│ ├── Passkey → WebAuthn flow → JWT issued
│ └── Password → continue below
│
└── No → password field shown
│
▼
User enters password
│
▼
core.auth validates password
│
├── Invalid → 401 (up to 5 attempts)
│
└── Valid → check: 2FA enabled?
│
├── No → JWT issued
│
└── Yes → check: 2FA type?
│
├── totp → show TOTP input → verify → JWT issued
├── webauthn → show biometric prompt → verify → JWT issued
└── recovery → show recovery code input → verify → JWT issuedNext: Frontend Architecture — login UI, account management, global dashboard.