Modern authentication implementation for 2026 - passkeys (WebAuthn), OAuth (Google, Apple), magic links, and cross-device sync. Use for passwordless-first authentication, social login setup, Supabase Auth, Next.js auth flows, and multi-factor authentication. Activate on "passkeys", "WebAuthn", "Google Sign-In", "Apple Sign-In", "magic link", "passwordless", "authentication", "login", "OAuth", "social login". NOT for session management without auth (use standard JWT docs), authorization/RBAC (use security-auditor), or API key management (use api-architect).
npx skills add https://github.com/curiositech/some_claude_skills --skill modern-auth-2026
Master passwordless-first authentication with passkeys, OAuth, magic links, and cross-device sync for modern web and mobile applications.
✅ USE this skill for:
❌ DO NOT use for:
security-auditor skillapi-architect skillsupabase-admin skill| 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 |
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
| 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 |
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)
Frontend:
{
"@simplewebauthn/browser": "^10.0.0",
"next-passkey-webauthn": "^2.0.0"
}
Backend:
{
"@simplewebauthn/server": "^10.0.0"
}
1. 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);
2. 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 });
}
}
3. 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 });
}
}
4. 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,
};
}
https://yourapp.comhttps://yourapp.supabase.co/auth/v1/callbackSupabase 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 };
}
⚠️ 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
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
)
)
}
// ✅ 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
<!-- 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>
// 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 });
Recovery Options (in order of security):
1. Backup Passkey (stored on different device)
2. Trusted Recovery Contact (delegated access)
3. Email verification + Security questions
4. Email-only recovery (last resort)
5. SMS recovery ⚠️ (vulnerable to SIM swap)
// 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>
);
}
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)
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
},
});
https://yourapp.com# 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"
Cause: Email confirmation not configured in Supabase Dashboard
Solution:
Cause: Apple .p8 key expired (6-month limit)
Solution:
Cause: Redirect URI mismatch
Solution:
https://yourproject.supabase.co/auth/v1/callbackCause: 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)
// ✅ 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
// 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 },
};
// 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...
}
Expert in secure backend coding practices specializing in input validation, authentication, and API security. Use PROACTIVELY for backend security implementations or security code reviews.
This skill should be used when the user asks to "perform cloud penetration testing", "assess Azure or AWS or GCP security", "enumerate cloud resources", "exploit cloud misconfigurations", "test O365 security", "extract secrets from cloud environments", or "audit cloud infrastructure". It provides comprehensive techniques for security assessment across major cloud platforms.
You are a dependency security expert specializing in vulnerability scanning, license compliance, and supply chain security. Analyze project dependencies for known vulnerabilities, licensing issues, outdated packages, and provide actionable remediation strategies.
Comprehensive Flow Nexus platform management - authentication, sandboxes, app deployment, payments, and challenges
This skill should be used when the user asks to "escalate privileges on Linux", "find privesc vectors on Linux systems", "exploit sudo misconfigurations", "abuse SUID binaries", "exploit cron jobs for root access", "enumerate Linux systems for privilege escalation", or "gain root access from low-privilege shell". It provides comprehensive techniques for identifying and exploiting privilege escalation paths on Linux systems.
Expert malware analyst specializing in defensive malware research, threat intelligence, and incident response. Masters sandbox analysis, behavioral analysis, and malware family identification. Handles static/dynamic analysis, unpacking, and IOC extraction. Use PROACTIVELY for malware triage, threat hunting, incident response, or security research.
This skill should be used when the user asks to "use Metasploit for penetration testing", "exploit vulnerabilities with msfconsole", "create payloads with msfvenom", "perform post-exploitation", "use auxiliary modules for scanning", or "develop custom exploits". It provides comprehensive guidance for leveraging the Metasploit Framework in security assessments.
Expert in secure mobile coding practices specializing in input validation, WebView security, and mobile-specific security patterns. Use PROACTIVELY for mobile security implementations or mobile security code reviews.
Take curiositech/modern-auth-2026 from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.