mcpbeat

Modern Auth 2026

curiositech/modern-auth-2026

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

7k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
177
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/curiositech/some_claude_skills --skill modern-auth-2026

The instruction itself

45 sections, as written by the author

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

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

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,
  };
}

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

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:

  • Verify redirect URI in Google Cloud Console matches exactly:
  • https://yourproject.supabase.co/auth/v1/callback
  • 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

Libraries

Research (2026)

How to use it

Copy the folder

Take curiositech/modern-auth-2026 from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

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.