
The best APIs for secure user authentication - Part 2
Part 2 of 2. Start with The best APIs for secure user authentication.
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/nextjsCreate 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/reactSet 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_hereThen 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/expressAttach 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
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
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.
Decision by scenario
Different teams have different constraints. This matrix maps common scenarios to the providers that fit best.
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.
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.
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:
- Clerk Next.js quickstart
- How Clerk Works
- Auth0 Developer Center
- WorkOS Docs
- AWS Cognito Getting Started
In this series
- The best APIs for secure user authentication
- The best APIs for secure user authentication - Part 2 (you are here)