Core Docs

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:

LevelFactorWhat It IsWho Uses It





0Password onlySingle factorUsers who haven't set up 2FA
1TOTPTime-based code from an appMost users, easy to set up
2Passkey / BiometricFace ID, Touch ID, fingerprint, Windows HelloUsers with modern devices
3Security keyPhysical 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 lockout


Time 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




TypeExamplesHow It Works




Platform authenticatorFace ID, Touch ID, Android biometrics, Windows HelloBuilt into the device. User proves identity with face/fingerprint/PIN.
Cross-platform authenticatorYubiKey, Nitrokey, Google TitanPhysical USB/NFC/Bluetooth key. User touches the key or taps NFC.
Synced passkeyiCloud Keychain, Google Password Manager, 1PasswordPasskey 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 success


Authentication 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 → 401


Passkey-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 needed


This 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:


TOTPPasskey / Biometric







SetupScan QR code, install appTap fingerprint, done
Login experienceType 6-digit codeTap fingerprint or glance at camera
Phishing resistantNo (user can be tricked into sharing code)Yes (bound to origin, can't be phished)
Device dependencyAuthenticator app on phoneBuilt into device OS
SyncManual backup of secretAutomatic via iCloud/Google/1Password
SecurityShared secret stored on serverPrivate 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 message


Recovery 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 attempts


Regeneration



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 → proceed


Grace 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 logged


This 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 issued





Next: Frontend Architecture — login UI, account management, global dashboard.