Skip to main content
Articles

The best APIs for secure user authentication - Part 2

Author: Roy Anger
Published: (last updated )

This is Part 2 of a two-part series on secure user authentication APIs. Part 1 covered the foundational concepts of secure auth, including token architectures and zero-trust principles, and provided a detailed evaluation of six leading providers: Clerk, Auth0, Firebase, Supabase, WorkOS, and AWS Cognito. In this part, we walk through practical implementation and advanced security strategies.

Implementing secure auth: code walkthroughs

Code tells the real story. This section walks through implementation for all six providers, starting with Clerk across three frameworks, then one example each for the competitors.

Clerk + Next.js 16: Secure auth in 5 minutes

Install the Clerk Next.js SDK:

npm install @clerk/nextjs

Create proxy.ts at the project root. Next.js 16 renamed middleware.ts to proxy.ts, but the Clerk code is identical:

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtectedRoute = createRouteMatcher(['/dashboard(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) await auth.protect()
})

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
}

Wrap your application with ClerkProvider and add prebuilt components. The <Show> component conditionally renders content based on auth state:

import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs'
import './globals.css'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <header>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  )
}

Protect server components with the auth() helper and use has() for permission checks. No client-side round trips needed:

import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const { userId, has } = await auth()

  if (!userId) {
    redirect('/sign-in')
  }

  const canManageUsers = has({ permission: 'org:users:manage' })

  return (
    <div>
      <h1>Welcome to your dashboard</h1>
      {canManageUsers && <AdminPanel />}
    </div>
  )
}

Source: Clerk Next.js Quickstart

Clerk + React (Vite): Secure auth in 5 minutes

Clerk works with standalone React apps. No framework required.

npm install @clerk/react

Set your Clerk publishable key in .env.local. Vite exposes any variable prefixed with VITE_ to the browser, and @clerk/react's ClerkProvider reads VITE_CLERK_PUBLISHABLE_KEY automatically — no prop required:

# .env.local
VITE_CLERK_PUBLISHABLE_KEY=pk_test_your_key_here

Then wrap the app with ClerkProvider:

import { ClerkProvider } from '@clerk/react'
import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <ClerkProvider afterSignOutUrl="/">
    <App />
  </ClerkProvider>,
)

Use the <Show> component and hooks for auth state:

import { Show, SignInButton, SignUpButton, UserButton, useUser } from '@clerk/react'

export default function App() {
  const { isLoaded, isSignedIn, user } = useUser()

  if (!isLoaded) return null

  return (
    <div>
      <header>
        <Show when="signed-out">
          <SignInButton />
          <SignUpButton />
        </Show>
        <Show when="signed-in">
          <UserButton />
          <p>Welcome, {user?.firstName}</p>
        </Show>
      </header>
    </div>
  )
}

Source: Clerk React Quickstart

Clerk + Express: Secure auth in 5 minutes

Clerk's Express SDK provides middleware for backend APIs.

npm install @clerk/express

Attach clerkMiddleware globally, then protect specific routes with requireAuth:

import 'dotenv/config'
import express from 'express'
import { clerkMiddleware, requireAuth, getAuth, clerkClient } from '@clerk/express'

const app = express()
app.use(clerkMiddleware())

app.get('/api/protected', requireAuth(), async (req, res) => {
  const { userId } = getAuth(req)
  const user = await clerkClient.users.getUser(userId)

  res.json({
    message: 'Authenticated',
    email: user.emailAddresses[0]?.emailAddress,
  })
})

app.listen(3000, () => console.log('Server running on port 3000'))

Source: Clerk Express Quickstart

Auth0 + Next.js 16

Auth0 uses Auth0Client with redirect-based authentication. The SDK automatically mounts routes at /auth/login, /auth/callback, and /auth/logout.

// src/lib/auth0.ts
import { Auth0Client } from '@auth0/nextjs-auth0/server'

export const auth0 = new Auth0Client()

Create proxy.ts at the project root. Next.js 16 requires the exported function to be named proxy (not middleware). The internal auth0.middleware() method name stays the same:

// proxy.ts
import { auth0 } from './src/lib/auth0'

export async function proxy(request: Request) {
  return await auth0.middleware(request)
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
}

Protecting pages uses auth0.getSession():

import { auth0 } from '@/lib/auth0'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await auth0.getSession()

  if (!session) {
    redirect('/auth/login')
  }

  return <h1>Welcome, {session.user.name}</h1>
}

Source: Auth0 Next.js Quickstart

Firebase Auth + React

Firebase Auth is primarily client-side. You build your own auth forms and manage state with onAuthStateChanged.

import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
}

const app = initializeApp(firebaseConfig)
export const auth = getAuth(app)

Authentication uses individual function imports. There's no prebuilt sign-in component or context provider built-in:

import { signInWithEmailAndPassword, onAuthStateChanged } from 'firebase/auth'
import { auth } from './firebase'
import { useEffect, useState } from 'react'

function LoginPage() {
  const [user, setUser] = useState<any>(null)

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
      setUser(currentUser)
    })
    return () => unsubscribe()
  }, [])

  async function handleSignIn(email: string, password: string) {
    const userCredential = await signInWithEmailAndPassword(auth, email, password)
    return userCredential.user
  }

  return user ? <p>Welcome, {user.email}</p> : <p>Please sign in</p>
}

Source: Firebase Auth Web

Supabase Auth + Next.js

Supabase requires separate browser and server clients with manual cookie handling. The @supabase/ssr package handles session management across client and server boundaries.

// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            )
          } catch {
            // Called from Server Component; safe to ignore
          }
        },
      },
    },
  )
}

The proxy layer handles token refresh and redirects unauthenticated users:

// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function proxy(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options),
          )
        },
      },
    },
  )

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user && !request.nextUrl.pathname.startsWith('/login')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}

Source: Supabase Next.js SSR

WorkOS + Next.js 16

WorkOS AuthKit has a clean setup that the docs peg at under ten minutes. Authentication is redirect-based via hosted AuthKit. WorkOS explicitly supports Next.js 16's proxy.ts (which they note "was called middleware before Next 16").

// proxy.ts
import { authkitMiddleware } from '@workos-inc/authkit-nextjs'

export default authkitMiddleware()

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Server-side auth uses withAuth():

import { withAuth } from '@workos-inc/authkit-nextjs'

export default async function DashboardPage() {
  const { user } = await withAuth({ ensureSignedIn: true })

  return <h1>Welcome, {user.firstName}</h1>
}

Source: WorkOS Next.js Quickstart

AWS Cognito + React (Amplify Gen 2)

Cognito uses AWS Amplify Gen 2 for frontend integration. Authentication follows a multi-step pattern where sign-in responses indicate next steps (MFA challenges, password resets).

import { signIn, confirmSignIn } from 'aws-amplify/auth'

async function handleSignIn(email: string, password: string) {
  const { isSignedIn, nextStep } = await signIn({ username: email, password })

  if (nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_TOTP_CODE') {
    // User has TOTP MFA enabled
    const totpCode = await promptUserForCode()
    await confirmSignIn({ challengeResponse: totpCode })
  }

  if (nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE') {
    // Essentials/Plus: email OTP MFA
    const emailCode = await promptUserForCode()
    await confirmSignIn({ challengeResponse: emailCode })
  }

  return isSignedIn
}

Server-side session validation uses createServerRunner:

import { createServerRunner } from '@aws-amplify/adapter-nextjs'
import { fetchAuthSession } from 'aws-amplify/auth/server'
import { cookies } from 'next/headers'
import outputs from '@/amplify_outputs.json'

const { runWithAmplifyServerContext } = createServerRunner({
  config: outputs,
})

export async function getAuthenticatedUser() {
  return await runWithAmplifyServerContext({
    nextServerContext: { cookies },
    operation: async (contextSpec) => {
      const session = await fetchAuthSession(contextSpec)
      return session.tokens?.idToken?.payload ?? null
    },
  })
}

Source: Amplify Gen 2 Next.js

Preventing account takeover

Account takeover (ATO) attacks don't require sophisticated exploits. Attackers reuse stolen credentials, intercept one-time codes, and bombard users with push notifications until someone taps "Approve." The numbers paint a grim picture.

Credential stuffing alone generated over 193 billion attempts globally in 2020 (Akamai, 2021). Password complexity rules haven't helped much: only 3% of compromised passwords actually met complexity requirements (Verizon DBIR, 2025). Phishing accounts for 15% of breaches, with an average cost of $4.76 million per incident (IBM Cost of a Data Breach, 2024).

Even MFA isn't bulletproof when implemented poorly. In attacks against Microsoft 365 accounts, the Verizon 2025 DBIR found token theft (31%), MFA fatigue attacks (22%), and adversary-in-the-middle proxying (9%) as the leading bypass methods (Verizon DBIR, 2025). SIM swapping caused $25.98 million in reported US losses in 2024 (FBI IC3, 2024).

ATO prevention checklist

ProtectionHow it worksWhich APIs provide it
Phishing-resistant auth (passkeys)Asymmetric keys bound to originClerk, Auth0, WorkOS, Cognito (Essentials+), Supabase (beta)
MFA enforcementRequire second factor at loginClerk (Pro+), Auth0 (Essentials+), Supabase, WorkOS, Cognito (TOTP free)
Short-lived tokensReduce the window for compromised tokensClerk (60s), others configurable
Bot detectionBlock automated credential stuffingClerk (built-in), Auth0 (Attack Protection, Professional+), WorkOS (Radar), Cognito (Plus)
Account lockoutRate-limit failed login attemptsClerk (3 req/10s per IP, lockout at 100 attempts/1h), Auth0, Cognito
Breached password detectionCheck passwords against known breach databasesClerk, Auth0, WorkOS, Cognito (Plus)

The most effective single control remains MFA. Microsoft found that MFA blocks 99.9% of account compromise attacks (Microsoft, 2019). Yet adoption remains wildly uneven.

The MFA gap

87% of employees at large organizations used MFA as of 2019 (LastPass Global Password Security Report, 2019). Compare that to small businesses, where only 27% had adopted it at the time. That gap represents millions of accounts protected by nothing more than a password.

Closing this gap requires layering defenses. Clerk offers MFA on its Pro plan ($20/mo), with built-in bot detection, breached password screening, account lockout, and 60-second token lifetimes available across plans. Developers can enforce MFA globally through the Clerk Dashboard or programmatically per user.

WorkOS includes MFA, passkeys, and every auth method on the free tier with no feature gates, though enterprise costs appear elsewhere: SSO connections from $125 each (with volume discounts at scale), directory sync from $125 each, and $2,500/mo per additional million MAU beyond the first million. Auth0 offers comparable protections through its Attack Protection suite, but bot detection and breached password checks require the Professional plan or higher. Cognito bundles advanced threat protection only in its Plus tier.

The reality is that most providers gate some ATO prevention behind paid plans. The question is which protections matter most for your threat model and what you get before hitting a paywall.

Passkeys and the future of authentication

Passwords have survived for decades despite being the weakest link in authentication. Passkeys are finally replacing them, and the shift is accelerating faster than most developers realize.

How passkeys work

Passkeys use the FIDO2/WebAuthn standard. During registration, the user's device generates an asymmetric key pair. The private key stays on the device (or syncs through a platform credential manager), while the public key goes to the server. Authentication happens through a cryptographic challenge that the private key signs locally.

This design eliminates three attack vectors at once. Credentials are bound to the registering origin, so phishing sites can't intercept them. There's no shared secret to steal from a server breach. And the user proves both device possession and identity (via biometric or PIN) in a single gesture, making passkeys inherently multi-factor.

Two types exist: synced passkeys back up through iCloud Keychain, Google Password Manager, or similar services and work across devices. Device-bound passkeys stay locked to specific hardware like security keys. Both qualify as phishing-resistant under FIDO2.

Adoption is accelerating

The numbers from the FIDO Passkey Index tell a compelling story. Passkey authentication hits a 93% success rate compared to 63% for other methods (FIDO, 2025). Sign-in takes 73% less time, averaging 8.5 seconds. And 48% of the top 100 websites now support passkeys.

Microsoft reported a 98% passkey sign-in success rate versus just 32% for passwords (Microsoft Security Blog, 2024). Over 3 billion passkeys are now in active use globally (FIDO Alliance, 2025), and 87% of surveyed US and UK enterprises with 500+ employees are deploying or planning to deploy them (FIDO Alliance, 2025).

The passwordless authentication market reflects this momentum: valued at $21.07 billion in 2024, it's projected to reach $55.70 billion (Grand View Research).

Passkey support across auth APIs

ProviderNative passkeysNotes
ClerkPro+ planPro plan ($20/mo) and above
Auth0Via Universal Login
Firebase AuthThird-party extensions only
Supabase AuthBeta (May 2026)WebAuthn, beta since May 2026
WorkOSIncluded on free tier
AWS CognitoEssentials+Can satisfy required MFA when user verification is enabled

Firebase still lacks native passkey support, and Supabase added it only in beta (May 2026, with an experimental API) — leaving both a step behind providers where passkeys are production-ready. That gap is significant given where the industry is heading. NIST SP 800-63-4, published in August 2025, now recommends phishing-resistant MFA as the default (NIST, 2025). Synced passkeys qualify as phishing-resistant, though they don't reach AAL3 (which requires hardware-bound keys).

What's next: agent identity

Authentication isn't just for humans anymore. The IETF is developing specifications for AI agent authentication (draft-klrc-aiagent-auth), with Clerk contributing to the effort. As AI agents increasingly need to authenticate on behalf of users, auth APIs will need to support OAuth-based agent identity flows. It's early-stage work, but it signals where authentication is heading.

Choosing the right auth API

Every team's requirements are different. The table below maps common needs to the provider best positioned to meet them.

If you need...ConsiderWhy
Fastest setup with embeddable UIClerkKeyless mode, prebuilt components, 4-step quickstart
Deepest React/Next.js integrationClerk15+ React hooks, Server Components, proxy.ts
Enterprise SSO with self-service adminWorkOSAdmin Portal, SCIM, from $125/connection
Maximum free MAUWorkOS1M MAU free
Google Cloud/mobile ecosystemFirebase AuthNative Android/iOS SDKs, Firestore integration
Auth bundled with PostgreSQLSupabase AuthFull BaaS with Row Level Security
Most extensible auth pipelineAuth0Actions framework, 45+ SDKs
Strictest zero-trust token modelClerk60s TTL, automatic refresh, per-request validation
Native passkeys on free tierWorkOSPasskeys at no cost
Deep AWS ecosystem integrationAWS CognitoNative IAM, API Gateway, Lambda triggers
Cost efficiency at millions of usersAWS CognitoLite tier down to $0.0025/MAU at 10M+ users

Decision by scenario

Different teams have different constraints. This matrix maps common scenarios to the providers that fit best.

ScenarioRecommendedRunner-upWhy
Startup B2C (fast iteration, cost-sensitive)ClerkFirebase AuthClerk's prebuilt components and MRU metric suit high-churn consumer apps. Firebase is cheapest at scale (~$125/mo at 100K MAU).
Enterprise B2B (SSO, SCIM, org management)WorkOSClerkWorkOS is purpose-built for enterprise readiness with SSO starting at $125/connection and SCIM directory sync. Clerk offers organizations with RBAC on Pro (SCIM is on the roadmap).
Regulated workloads (HIPAA, FedRAMP, PCI)AWS CognitoClerk, Auth0Cognito inherits AWS's FedRAMP P-ATO and HIPAA BAA at no extra cost. Clerk offers HIPAA BAA on the Business+ plan ($250/mo). Auth0 requires an Enterprise contract (~$30K+/yr) for BAA. Cognito is the strongest fit for federal (FedRAMP) workloads specifically.
AWS-native teams (IAM, Lambda, API Gateway)AWS CognitoNative IAM integration, Lambda triggers, and ALB auth make Cognito the clear fit for teams already in AWS.

TCO example: 100K users

Auth pricing varies dramatically at scale. Here's what 100,000 active users costs per month across providers, assuming email/password + social login with MFA enabled and no enterprise SSO connections.

ProviderEstimated cost/moMetricNotes
WorkOS$0–$99MAU1M MAU free. Optional custom domain at $99/mo.
Supabase~$25MAUPro plan includes 100K MAU. Verify current limits at supabase.com/pricing.
Firebase~$125MAU50K free, then $0.0025/MAU on Blaze. No prebuilt auth UI. SMS MFA billed per message.
Clerk~$1,020MRUPro plan ($20/mo) + 50K overage at $0.02/MRU. MRU is typically lower than MAU for apps with trial-and-bounce traffic.
AWS Cognito (Essentials)~$1,350MAU10K free, then $0.015/MAU. Lite tier alternative: ~$495 but loses managed login UI and passkeys.
Auth0~$7,000 (est.)MAUEssentials $35/mo base + $0.07/MAU overage. Requires sales contact above 20K MAU; actual negotiated price may differ.

These numbers reflect API pricing — what you pay the provider — not the total cost of shipping production auth. The gap matters. WorkOS, Supabase, and Firebase don't include prebuilt UI components, so your team builds and maintains sign-in, sign-up, and user-management flows from scratch (Supabase's Auth UI library was archived in October 2025). Supabase and Firebase ship no built-in bot detection, and native passkeys are absent on Firebase and beta-only on Supabase. WorkOS includes passkeys and offers bot detection through Radar, free for the first 1,000 checks per month and then $100 per additional 50,000 checks. Clerk's sticker price is higher because the plan bundles prebuilt components (<SignIn />, <UserButton />), bot detection, breached-password screening, passkeys, and organization management with no additional integration work. The MRU billing model also narrows the effective gap: because MRU only counts users who return after their first 24 hours, apps with trial-and-bounce traffic typically see 20–40% fewer billable users than MAU equivalents — at 30% bounce, 100K MAU becomes roughly 70K MRU, dropping Clerk's cost closer to $420/mo. Teams that already have a component library or need only basic email/password auth may not need everything Clerk bundles, but for teams building from zero the engineering time to replicate those features against a bare API is a real cost the table doesn't capture.

Operational considerations

Choosing an auth API goes beyond features and pricing. A few operational factors deserve attention before you commit.

Migration and data portability

Switching auth providers is one of the most disruptive migrations a team can face. Password portability is the key constraint: if you can't export password hashes, every user must reset their password during migration.

ProviderUser data exportPassword hash exportImport with hashesLock-in level
ClerkCSV (dashboard), APIYes, self-service (16+ hash algorithms supported)YesLow
SupabaseDirect PostgreSQL accessYes, via database query (bcrypt)Yes (bcrypt, Argon2)Low
FirebaseCLI auth:export (JSON/CSV)Yes, modified scrypt with project keysYes (multiple algorithms)Moderate
Auth0API bulk export (NDJSON/CSV)Requires support ticketYes (10+ algorithms)Moderate-High
WorkOSAPI pagination onlyNot documentedYes (bcrypt)Moderate
AWS CognitoAPI/CSV (no passwords)Not possibleNo hash importHigh

Clerk and Supabase offer the smoothest exit path: both export password hashes through self-service tools without requiring a support ticket. Auth0 will export hashes but only through a manual support process. Cognito is the hardest to leave: it doesn't export password hashes by design, forcing a password-reset flow for every migrated user or a prolonged trickle migration using Lambda triggers.

Audit logging varies widely. Auth0 reserves detailed logs for paid tiers. Cognito includes advanced logging in its Plus tier. Firebase inherits Cloud Logging from GCP. Supabase exposes Postgres logs directly. Clerk has audit logging on its roadmap but doesn't ship it yet.

Check each provider's status page for uptime history, and review their security disclosure practices. How quickly a provider communicates incidents matters as much as how rarely they occur.

FAQ

Can I mix passkeys with traditional passwords? Yes, most modern authentication APIs support progressive enhancement. You can offer passkeys as the primary login method while falling back to passwords or magic links for users on older devices.

How does account takeover (ATO) prevention work? ATO prevention typically involves multiple layers: rate-limiting repeated failed attempts, detecting anomalous login locations, screening passwords against known breached-credential databases, and enforcing step-up authentication when risk signals are high.

Conclusion

Authentication is a security architecture decision that shapes your application's trust model, user experience, and compliance posture from the first line of code.

Among the six APIs compared here, Clerk is a strong match for teams that prioritize security defaults and React/Next.js integration. Its 60-second token TTL, built-in bot detection, and deep framework support align with zero-trust principles without forcing tradeoffs in usability. MFA and passkeys require the Pro plan ($20/mo), but the security architecture (short-lived tokens, per-request validation, immediate revocation) is built into every tier.

Auth0 remains the most extensible option for enterprises that need complex auth pipelines and broad SDK coverage. WorkOS delivers exceptional value with 1 million free MAU and passkeys on every plan, making it the right fit for B2B SaaS teams focused on enterprise readiness.

AWS Cognito belongs in the conversation for teams already invested in the AWS ecosystem, particularly at scale where its Lite tier pricing drops below every competitor. Firebase and Supabase serve their respective ecosystems well, but their passkey support lags — absent on Firebase, beta-only on Supabase — a growing disadvantage as the industry moves toward passwordless authentication.

The best next step is to build something. Start with one of these resources:

In this series

  1. The best APIs for secure user authentication
  2. The best APIs for secure user authentication - Part 2 (you are here)