Modern Authentication Expert (2026)
Master passwordless-first authentication with passkeys, OAuth, magic links, and cross-device sync for modern web and mobile applications.
When to Use
✅ USE this skill for:
-
Implementing passkeys/WebAuthn authentication
-
Google and Apple OAuth social login
-
Supabase Auth configuration and troubleshooting
-
Magic link/OTP passwordless flows
-
Cross-device authentication sync
-
MFA implementation (TOTP, passkeys as 2FA)
-
Email/SMS recovery flows
-
App Store compliance for social login
❌ DO NOT use for:
-
Session management without auth context → use standard JWT patterns
-
Authorization/RBAC policies → use security-auditor skill
-
API key management → use api-architect skill
-
Supabase RLS policies → use supabase-admin skill
2026 Authentication Landscape
Industry Adoption Stats
-
Passkeys: 87% of US/UK companies now use passkeys (FIDO Alliance)
-
Google: 800+ million accounts use passkeys
-
Amazon: 175 million users created passkeys in first year
-
Trend: Passwordless is the security baseline, not a luxury
Key Standards
Standard Purpose Status
WebAuthn L2 Browser passkey API Fully supported
FIDO2/CTAP2 Cross-platform passkeys Mature
OAuth 2.1 Simplified OAuth Replacing 2.0
OAuth3 Short-lived tokens Emerging
Passkey Sync iCloud/Google sync Production
Architecture: Passwordless-First Design
Recommended Auth Hierarchy (2026)
Primary Methods (Phishing-Resistant): ├── 1. Passkeys (WebAuthn) ← PREFERRED │ ├── Platform authenticators (Face ID, Touch ID, Windows Hello) │ └── Roaming authenticators (YubiKey, security keys) ├── 2. Social OAuth │ ├── Google Sign-In (synced passkeys) │ └── Apple Sign-In (privacy-focused) │ Fallback Methods (Lower Security): ├── 3. Magic Links (email-based) ├── 4. Email OTP (time-limited codes) └── 5. SMS OTP (deprecated - SIM swap risk) ⚠️ SMS should be last resort only
Legacy (Avoid): └── 6. Password + Email ← DISCOURAGE
Security Tier Comparison
Method Phishing-Resistant Device-Bound Sync-Capable Friction
Passkeys ✅ Yes ✅ Yes ✅ Yes Low
Hardware Key ✅ Yes ✅ Yes ❌ No Medium
Google OAuth ⚠️ Partial ❌ No ✅ Yes Low
Apple OAuth ⚠️ Partial ❌ No ✅ Yes Low
Magic Link ❌ No ❌ No ✅ Yes Medium
Email OTP ❌ No ❌ No ✅ Yes Medium
SMS OTP ❌ No ❌ No ❌ No Medium
Password ❌ No ❌ No ✅ Yes Low
Passkeys (WebAuthn) Implementation
How Passkeys Work
Registration Flow: ┌──────────┐ ┌──────────┐ ┌──────────┐ │ User │─────▶│ Browser │─────▶│ Server │ │ │ │ WebAuthn │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ 1. User clicks │ │ │ "Register" │ │ │ │ 2. Server sends │ │ │◀─ challenge + │ │ │ user info │ │ 3. Device shows │ │ │◀─ biometric │ │ │ │ │ │ 4. User │ │ │─▶ authenticates │ │ │ │ 5. Send public │ │ │─▶ key + signed │ │ │ challenge │ │ │ │ │ │ 6. Server stores│ │ │◀─ public key │ └──────────────────┴──────────────────┘
Key Points:
- Private key NEVER leaves device
- Server only stores public key
- Biometric data stays local
- Credential bound to domain (anti-phishing)
Library Recommendations
Frontend:
{ "@simplewebauthn/browser": "^10.0.0", "next-passkey-webauthn": "^2.0.0" }
Backend:
{ "@simplewebauthn/server": "^10.0.0" }
Next.js Passkey Implementation
- Database Schema (Supabase):
-- Store passkey credentials CREATE TABLE passkey_credentials ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE, credential_id text UNIQUE NOT NULL, public_key bytea NOT NULL, counter integer DEFAULT 0, transports text[], -- e.g., ['internal', 'hybrid'] device_type text, -- 'platform' or 'cross-platform' backed_up boolean DEFAULT false, created_at timestamptz DEFAULT now(), last_used_at timestamptz );
CREATE INDEX idx_passkey_user_id ON passkey_credentials(user_id); CREATE INDEX idx_passkey_credential_id ON passkey_credentials(credential_id);
-- RLS policies ALTER TABLE passkey_credentials ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own credentials" ON passkey_credentials FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own credentials" ON passkey_credentials FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own credentials" ON passkey_credentials FOR DELETE USING (auth.uid() = user_id);
- Registration API Route (app/api/passkeys/register/route.ts):
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'; import { createClient } from '@/lib/supabase/server';
const RP_NAME = 'Your App Name'; const RP_ID = process.env.NODE_ENV === 'production' ? 'yourapp.com' : 'localhost';
export async function POST(request: Request) { const supabase = createClient(); const { data: { user } } = await supabase.auth.getUser();
if (!user) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); }
const { step, credential } = await request.json();
if (step === 'options') { // Get existing credentials to exclude const { data: existingCreds } = await supabase .from('passkey_credentials') .select('credential_id') .eq('user_id', user.id);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: user.id,
userName: user.email!,
userDisplayName: user.user_metadata?.display_name || user.email!,
attestationType: 'none', // For privacy
excludeCredentials: existingCreds?.map(c => ({
id: Buffer.from(c.credential_id, 'base64url'),
type: 'public-key',
})) || [],
authenticatorSelection: {
residentKey: 'preferred', // Discoverable credentials
userVerification: 'preferred', // Biometric when available
authenticatorAttachment: 'platform', // Device-bound (not roaming keys)
},
});
// Store challenge in session (or use signed JWT)
await supabase.from('auth_challenges').upsert({
user_id: user.id,
challenge: options.challenge,
expires_at: new Date(Date.now() + 5 * 60 * 1000), // 5 min
});
return Response.json(options);
}
if (step === 'verify') { // Get stored challenge const { data: challengeData } = await supabase .from('auth_challenges') .select('challenge') .eq('user_id', user.id) .single();
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challengeData!.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_APP_URL!,
expectedRPID: RP_ID,
});
if (verification.verified && verification.registrationInfo) {
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
await supabase.from('passkey_credentials').insert({
user_id: user.id,
credential_id: Buffer.from(credentialID).toString('base64url'),
public_key: Buffer.from(credentialPublicKey),
counter,
transports: credential.response.transports,
device_type: verification.registrationInfo.credentialDeviceType,
backed_up: verification.registrationInfo.credentialBackedUp,
});
return Response.json({ success: true });
}
return Response.json({ error: 'Verification failed' }, { status: 400 });
} }
- Authentication API Route (app/api/passkeys/authenticate/route.ts):
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'; import { createClient } from '@/lib/supabase/server';
export async function POST(request: Request) { const supabase = createClient(); const { step, credential, email } = await request.json();
if (step === 'options') { // For discoverable credentials, email is optional let userCredentials = [];
if (email) {
const { data: user } = await supabase
.from('profiles')
.select('id')
.eq('email', email)
.single();
if (user) {
const { data: creds } = await supabase
.from('passkey_credentials')
.select('credential_id, transports')
.eq('user_id', user.id);
userCredentials = creds || [];
}
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: userCredentials.length ? userCredentials.map(c => ({
id: Buffer.from(c.credential_id, 'base64url'),
type: 'public-key',
transports: c.transports,
})) : undefined, // Empty = discoverable credential flow
});
// Store challenge
await supabase.from('auth_challenges').upsert({
challenge_id: options.challenge,
challenge: options.challenge,
expires_at: new Date(Date.now() + 5 * 60 * 1000),
});
return Response.json(options);
}
if (step === 'verify') { // Find credential const credentialId = Buffer.from(credential.id, 'base64url').toString('base64url');
const { data: storedCred } = await supabase
.from('passkey_credentials')
.select('*, profiles!inner(email)')
.eq('credential_id', credentialId)
.single();
if (!storedCred) {
return Response.json({ error: 'Credential not found' }, { status: 401 });
}
// Get challenge
const { data: challengeData } = await supabase
.from('auth_challenges')
.select('challenge')
.eq('challenge_id', credential.response.clientDataJSON.challenge)
.single();
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challengeData!.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_APP_URL!,
expectedRPID: RP_ID,
authenticator: {
credentialID: Buffer.from(storedCred.credential_id, 'base64url'),
credentialPublicKey: storedCred.public_key,
counter: storedCred.counter,
},
});
if (verification.verified) {
// Update counter
await supabase
.from('passkey_credentials')
.update({
counter: verification.authenticationInfo.newCounter,
last_used_at: new Date(),
})
.eq('id', storedCred.id);
// Create Supabase session
const { data: session } = await supabase.auth.admin.generateLink({
type: 'magiclink',
email: storedCred.profiles.email,
});
return Response.json({
success: true,
session: session.properties?.hashed_token
});
}
return Response.json({ error: 'Verification failed' }, { status: 401 });
} }
- Frontend Hook (hooks/usePasskey.ts):
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; import { useState } from 'react';
export function usePasskey() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null);
const registerPasskey = async () => { setIsLoading(true); setError(null);
try {
// Get options from server
const optionsRes = await fetch('/api/passkeys/register', {
method: 'POST',
body: JSON.stringify({ step: 'options' }),
});
const options = await optionsRes.json();
// Start WebAuthn registration
const credential = await startRegistration(options);
// Verify with server
const verifyRes = await fetch('/api/passkeys/register', {
method: 'POST',
body: JSON.stringify({ step: 'verify', credential }),
});
if (!verifyRes.ok) {
throw new Error('Verification failed');
}
return true;
} catch (err: any) {
// Handle user cancellation gracefully
if (err.name === 'NotAllowedError') {
setError('Passkey registration cancelled');
} else {
setError(err.message);
}
return false;
} finally {
setIsLoading(false);
}
};
const authenticateWithPasskey = async (email?: string) => { setIsLoading(true); setError(null);
try {
const optionsRes = await fetch('/api/passkeys/authenticate', {
method: 'POST',
body: JSON.stringify({ step: 'options', email }),
});
const options = await optionsRes.json();
const credential = await startAuthentication(options);
const verifyRes = await fetch('/api/passkeys/authenticate', {
method: 'POST',
body: JSON.stringify({ step: 'verify', credential }),
});
if (!verifyRes.ok) {
throw new Error('Authentication failed');
}
const { session } = await verifyRes.json();
// Exchange for Supabase session
// ...
return true;
} catch (err: any) {
if (err.name === 'NotAllowedError') {
setError('Passkey authentication cancelled');
} else {
setError(err.message);
}
return false;
} finally {
setIsLoading(false);
}
};
const isSupported = typeof window !== 'undefined' && window.PublicKeyCredential !== undefined;
return { registerPasskey, authenticateWithPasskey, isSupported, isLoading, error, }; }
OAuth: Google Sign-In
Setup Requirements
Google Cloud Console:
-
Create OAuth 2.0 Client ID (Web application)
-
Add authorized JavaScript origins: https://yourapp.com
-
Add authorized redirect URIs: https://yourapp.supabase.co/auth/v1/callback
Supabase Dashboard:
-
Authentication → Providers → Google
-
Add Client ID and Client Secret
-
Enable "Sign in with Google"
Implementation
Supabase Client (Next.js):
import { createClient } from '@/lib/supabase/client';
async function signInWithGoogle() { const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: ${window.location.origin}/auth/callback,
queryParams: {
access_type: 'offline', // For refresh tokens
prompt: 'consent', // Force consent screen
},
},
});
if (error) { console.error('Google sign-in error:', error); } }
Native Mobile (React Native/Expo):
import * as Google from 'expo-auth-session/providers/google'; import { createClient } from '@supabase/supabase-js';
export function useGoogleAuth() { const [request, response, promptAsync] = Google.useIdTokenAuthRequest({ clientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID, iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, androidClientId: process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID, });
useEffect(() => { if (response?.type === 'success') { const { id_token } = response.params;
supabase.auth.signInWithIdToken({
provider: 'google',
token: id_token,
});
}
}, [response]);
return { signIn: () => promptAsync(), isLoading: !request }; }
OAuth: Apple Sign-In
App Store Requirements (2024+)
⚠️ Critical Compliance Rule:
Apps that use third-party login (Google, Facebook, etc.) must also offer an equivalent privacy-focused option. Sign in with Apple satisfies this requirement.
Required if you offer: Google, Facebook, Twitter, Amazon, WeChat login Exception: Enterprise/education apps with existing SSO
Setup Requirements
Apple Developer Portal:
-
Enable "Sign in with Apple" capability
-
Create Service ID for web
-
Create Key (.p8 file) for token generation
-
⚠️ Key expires every 6 months - set calendar reminder!
Supabase Dashboard:
-
Authentication → Providers → Apple
-
Add Service ID, Team ID, Key ID
-
Upload .p8 key file
Implementation
Web (Supabase):
async function signInWithApple() { const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: ${window.location.origin}/auth/callback,
},
});
if (error) { console.error('Apple sign-in error:', error); } }
Native iOS (Swift):
import AuthenticationServices
func handleAppleSignIn() async throws { let appleIDProvider = ASAuthorizationAppleIDProvider() let request = appleIDProvider.createRequest() request.requestedScopes = [.fullName, .email]
let result = try await performSignIn(request)
// Extract ID token
guard let identityToken = result.credential.identityToken,
let tokenString = String(data: identityToken, encoding: .utf8) else {
throw AuthError.missingToken
}
// Sign in to Supabase
try await supabase.auth.signInWithIdToken(
credentials: .init(
provider: .apple,
idToken: tokenString
)
)
}
Magic Links (Email Passwordless)
Best Practices
// ✅ Good: Short TTL, single-use
const { error } = await supabase.auth.signInWithOtp({
email: user.email,
options: {
emailRedirectTo: ${origin}/auth/callback,
shouldCreateUser: true, // Auto-create on first login
},
});
// Configure in Supabase Dashboard: // - Magic Link expiry: 5-10 minutes (shorter is safer) // - Rate limit: 3 per hour per email
Email Template Customization
<!-- Supabase Dashboard → Auth → Email Templates → Magic Link --> <h2>Sign in to {{ .SiteURL }}</h2> <p>Click the link below to sign in. This link expires in 10 minutes.</p> <p><a href="{{ .ConfirmationURL }}">Sign in to Your Account</a></p> <p>If you didn't request this, you can safely ignore this email.</p>
Recovery Flows
Email Recovery (Password Reset)
// Request reset
await supabase.auth.resetPasswordForEmail(email, {
redirectTo: ${origin}/auth/update-password,
});
// Update password (on /auth/update-password page) await supabase.auth.updateUser({ password: newPassword });
Account Recovery Hierarchy
Recovery Options (in order of security):
- Backup Passkey (stored on different device)
- Trusted Recovery Contact (delegated access)
- Email verification + Security questions
- Email-only recovery (last resort)
- SMS recovery ⚠️ (vulnerable to SIM swap)
Implementing Backup Passkeys
// Prompt user to register backup device after primary function PromptBackupPasskey() { const [hasBackup, setHasBackup] = useState(false); const { data: credentials } = usePasskeyCredentials();
useEffect(() => { // Check if user has only one passkey if (credentials?.length === 1) { setHasBackup(false); } }, [credentials]);
if (hasBackup) return null;
return ( <div className="bg-amber-50 border border-amber-200 p-4 rounded-lg"> <h3>Add a Backup Passkey</h3> <p>Register a passkey on another device to ensure account recovery.</p> <Button onClick={registerPasskey}>Add Backup Device</Button> </div> ); }
Cross-Device Sync
How Passkey Sync Works
Device A (iPhone) iCloud Keychain Device B (Mac) ┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐ │ Create Passkey │──────────▶│ E2E Encrypt │──────────▶│ Passkey Ready │ │ for example.com │ │ & Sync │ │ to use │ └─────────────────┘ └─────────────┘ └─────────────────┘
Google Password Manager:
- Android devices synced
- Chrome browser synced
- Windows via Chrome
Apple iCloud Keychain:
- All Apple devices synced
- Safari on all platforms
- Shared with Family Sharing (optional)
Cross-Platform Authentication (QR Code)
When user wants to sign in on a device without their passkey:
// Device A shows QR code // User scans with phone (Device B) that has passkey // Phone authenticates via Bluetooth proximity
// This is handled automatically by the browser's WebAuthn implementation // No additional code needed - just allow hybrid transports:
const options = await generateAuthenticationOptions({ rpID: RP_ID, authenticatorSelection: { // Allow cross-device (QR code) authentication authenticatorAttachment: undefined, // Don't restrict }, });
Supabase Auth Configuration Checklist
Dashboard Settings
Authentication → Settings:
-
Site URL: https://yourapp.com
-
Redirect URLs: Add all valid callbacks
-
JWT Expiry: 3600 (1 hour)
-
Enable email confirmations: Yes
Authentication → Providers → Email:
-
Enable Email: Yes
-
Confirm email: Yes (recommended)
-
Secure email change: Yes
-
Double confirm email: No (reduces friction)
Authentication → Email Templates:
-
Customize all templates
-
Test email delivery
-
Set appropriate expiry times
Authentication → Rate Limiting:
-
Email: 3 per hour
-
SMS: 3 per hour
-
Magic links: 3 per 5 minutes
Environment Variables
Required
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-key
Google OAuth
GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_SECRET=your-client-secret
Apple OAuth
APPLE_SERVICE_ID=your-service-id APPLE_TEAM_ID=your-team-id APPLE_KEY_ID=your-key-id APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
Passkeys
PASSKEY_RP_ID=yourapp.com PASSKEY_RP_NAME="Your App Name"
Common Issues & Solutions
Issue: Sign-up says "Check email" but no email arrives
Cause: Email confirmation not configured in Supabase Dashboard
Solution:
-
Go to Supabase Dashboard → Authentication → Providers → Email
-
Verify "Confirm email" is enabled
-
Check email templates are configured
-
Verify SMTP settings (or use Supabase's built-in email)
-
Check spam folder
Issue: Apple Sign-In suddenly stops working
Cause: Apple .p8 key expired (6-month limit)
Solution:
-
Generate new key in Apple Developer Portal
-
Update key in Supabase Dashboard
-
Set calendar reminder for next expiry
Issue: Google OAuth redirect error
Cause: Redirect URI mismatch
Solution:
-
Verify redirect URI in Google Cloud Console matches exactly:
-
Check for trailing slashes
-
Ensure HTTP vs HTTPS matches
Issue: Passkey not syncing between devices
Cause: Credential created with wrong attachment type
Solution:
// Use 'platform' for synced credentials authenticatorAttachment: 'platform', // NOT 'cross-platform'
// 'cross-platform' = hardware security keys (no sync) // 'platform' = device biometrics (sync via iCloud/Google)
Security Best Practices
Token Management
// ✅ Good: Short-lived access tokens + refresh const session = await supabase.auth.getSession(); // Access token: 1 hour // Refresh token: 7 days (rotate on use)
// ✅ Good: Secure token storage // Browser: HttpOnly cookies (Supabase handles this) // Mobile: Secure Keychain/Keystore
// ❌ Bad: Long-lived tokens in localStorage localStorage.setItem('token', longLivedToken); // DON'T
Rate Limiting
// Implement rate limiting on auth endpoints const rateLimit = { signIn: { max: 5, windowMs: 15 * 60 * 1000 }, // 5 per 15 min signUp: { max: 3, windowMs: 60 * 60 * 1000 }, // 3 per hour passwordReset: { max: 3, windowMs: 60 * 60 * 1000 }, passkey: { max: 10, windowMs: 15 * 60 * 1000 }, };
Secure Defaults
// Always verify email on signup
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: ${origin}/auth/callback,
// Supabase will only create confirmed user after email click
},
});
// Require email verification for sensitive actions async function sensitiveAction(userId: string) { const { data: user } = await supabase.auth.getUser();
if (!user?.email_confirmed_at) { throw new Error('Please verify your email first'); }
// Proceed with action... }
References
Official Documentation
-
Google Passkeys Developer Guide
-
Apple Sign in with Apple
-
Supabase Auth Documentation
-
WebAuthn Spec
Libraries
-
SimpleWebAuthn - Recommended WebAuthn library
-
Corbado - Passkey-as-a-service option
-
Hanko - Open-source passkey server
Research (2026)
-
Authentication Trends in 2026
-
Passwordless & MFA in 2026
-
FIDO Alliance Passkey Index