modern-auth-2026

Modern Authentication Expert (2026)

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "modern-auth-2026" with this command: npx skills add erichowens/some_claude_skills/erichowens-some-claude-skills-modern-auth-2026

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

  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);

  1. 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 });

} }

  1. 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 });

} }

  1. 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:

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

  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)

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:

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

video-processing-editing

No summary provided by upstream source.

Repository SourceNeeds Review
General

cv-creator

No summary provided by upstream source.

Repository SourceNeeds Review
General

mobile-ux-optimizer

No summary provided by upstream source.

Repository SourceNeeds Review