Full-Stack Questions

Auth Libraries, OAuth and OIDC

OAuth vs OpenID Connect, code flow with PKCE, tokens and rotation, Auth.js and Better Auth, protecting Next.js 16, RBAC, passkeys and MFA.

Lesson 2242 min read#auth#oauth

On this page

  1. Words used in this lesson
  2. OAuth versus OIDC
  3. The authorization code flow with PKCE
  4. Tokens: access, refresh, ID
  5. Choosing: Auth.js, Better Auth, Clerk, Auth0, or your own
  6. Session strategies: database or JWT
  7. Protecting a Next.js 16 app
  8. RBAC, ABAC and multi-tenant authorization
  9. Passkeys and MFA
  10. CSRF with OAuth state, and account linking
  11. Common mistakes
  12. Interview questions
  13. Key takeaways
  14. What's next

In one line: OAuth 2.0 lets an app get limited access to a user's data without ever seeing their password. OpenID Connect (OIDC) adds one thing on top: a signed answer to "who just logged in?". In a Next.js app you almost never write this flow yourself. You pick a library or a hosted service, and then you must still protect every route, Server Action and API endpoint on the server.

At 4+ years, auth questions go past "what is a JWT?". Interviewers want to know whether you understand the login protocol (code, PKCE, state, tokens), whether you can choose between Auth.js, Better Auth, Clerk and Auth0 and defend it, and whether you know the places where Next.js apps are really broken: a check in only one layer, a Server Action with no check, a tenant id taken from the client.

Where to find the basics: how cookies, localStorage, CSRF and CORS work is in Browser Contract, Lesson 3 and Lesson 4. Sessions versus JWTs are in Browser Contract, Lesson 2. Password storage, ownership checks and RBAC basics are in Backend, Lesson 6. The short library comparison table is in Full-Stack Q&A, Lesson 13. This lesson does not repeat those. It adds the protocol, the library choices, Next.js 16 protection patterns, multi-tenancy, passkeys and MFA.

Every code sample here was run or type-checked: the OAuth and token code ran on Node.js 22 with jose 6, and the Next.js, Auth.js and Better Auth snippets compiled in strict TypeScript against Next.js 16.4, next-auth 5.0.0-beta.32 and better-auth 1.7.

Words used in this lesson

  • OAuth 2.0 is a framework (a set of rules) that lets one app get limited, revocable access to a user's data held by another service. Everyday comparison: a hotel key card. You do not give the cleaner your house key. You give a card that opens one door for one day.
  • OpenID Connect (OIDC) is a thin layer on top of OAuth 2.0 that adds a standard way to learn who the user is. Difference in one line: OAuth answers "what may this app do?" (authorization); OIDC answers "who is this person?" (authentication).
  • The four roles. The resource owner is the user. The client is your app. The authorization server (also called the identity provider, or IdP, such as Google) logs the user in and hands out tokens. The resource server is the API that accepts the tokens.
  • A public client is a client that cannot keep a secret, such as a browser SPA or a mobile app. A confidential client can, such as your Next.js server.
  • A grant is one way of getting a token. The authorization code grant is the main one for people logging in: the browser carries a short, one-time code, and your server swaps it for tokens.
  • PKCE (Proof Key for Code Exchange, said "pixy") is an add-on to the code grant. The client invents a random secret (the verifier), sends only its hash (the challenge) at the start, and shows the real secret when it swaps the code. Only the client that started the flow can finish it.
  • state is a random value your app sends at the start and checks at the end. It ties the response to a login that this browser started. nonce is a random value put inside the ID token. It ties the ID token to one login request.
  • An access token is a short-lived credential that an API accepts. A refresh token is a longer-lived credential used only to get new access tokens. An ID token is an OIDC token (always a JWT) that tells your app who logged in.
  • A JWT (JSON Web Token) is a signed text token that carries claims (facts) such as sub (subject, the user id), iss (issuer), aud (audience), and exp (expiry time). JWKS is a public list of the issuer's signing keys, so anyone can check a signature.
  • A scope is a name for a permission the client asks for, such as openid email or repo:read.
  • A session is the server's memory that "this browser is logged in". A session strategy is how you keep it: in a database, or inside a signed cookie.
  • RBAC (role-based access control) gives permissions to roles, and roles to users. ABAC (attribute-based access control) decides using facts (attributes) about the user, the thing and the situation.
  • A tenant is one customer organization in a multi-customer app (SaaS). Multi-tenant means many tenants share one app and one database.
  • A passkey is a login credential made of a key pair that lives on your device. MFA (multi-factor authentication) asks for two or more different kinds of proof. TOTP (time-based one-time password) is the six-digit code that changes every 30 seconds in an authenticator app.

OAuth versus OIDC

A common mix-up: "I used OAuth to log in with Google." Strictly, you used OIDC. OAuth alone says "this app may read the user's calendar". It does not promise anything about who the user is. OIDC adds three things: the openid scope, an ID token, and a standard userinfo endpoint.

OAuth 2.0OpenID Connect
Question it answersWhat may this app do?Who is this user?
Main tokenAccess tokenID token (a JWT)
Who reads the main tokenThe API (resource server)Your app (the client)
Typical use"Read my GitHub repos", "post to my calendar""Sign in with Google"
Token format defined?No. Opaque or JWT, the server choosesYes: ID token is always a JWT

Never send the ID token to your API as proof of access, and never read the access token in your frontend to find out who the user is. The ID token is meant for your app. The access token is meant for the API. The format of an access token is not fixed by OAuth, so your client should treat it as an opaque string.

The authorization code flow with PKCE

This is the flow every library runs for "Sign in with Google" or "Sign in with GitHub".

  1. Your server creates a random verifier, hashes it into a challenge, and also creates state (and a nonce for OIDC). It stores them (in a short-lived, HttpOnly cookie or a server store) and redirects the browser to the provider's /authorize URL with response_type=code, client_id, redirect_uri, scope, state, code_challenge and code_challenge_method=S256.
  2. The user logs in at the provider and approves the scopes.
  3. The provider redirects the browser back to your redirect_uri with ?code=...&state=....
  4. Your server checks that state matches the one it stored. Then it calls the provider's /token endpoint directly (server to server) with the code, the verifier and its client credentials.
  5. The provider hashes the verifier, compares it to the challenge from step 1, and returns an access token, a refresh token and (for OIDC) an ID token.
  6. Your server validates the ID token, finds or creates the user, and starts your own session (a cookie).

The key idea: the code travels through the browser, where it can leak (a browser extension, a log, a proxy). On its own it is useless, because swapping it needs the verifier, which never left your server.

Here is the PKCE part, run against the official test vector from RFC 7636:

JavaScript
import { createHash, randomBytes } from 'node:crypto'const b64url = (buf) => Buffer.from(buf).toString('base64url')const challengeOf = (verifier) =>  b64url(createHash('sha256').update(verifier).digest())// RFC 7636 appendix B test vectorchallengeOf('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')// -> 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'const verifier = b64url(randomBytes(32)) // 43 charactersconst challenge = challengeOf(verifier)  // sent at /authorize; the verifier is sent only at /token

I also ran a small in-memory authorization server around this. A stolen code with a wrong verifier was rejected (PKCE failed). A code used a second time was rejected (code reuse). So the code alone is not enough for an attacker.

What changed in recent years. The OAuth 2.0 security best-current-practice document (RFC 9700, 2025) and the OAuth 2.1 draft agree: the implicit grant (tokens in the URL fragment) and the password grant (the app collects the user's password) should not be used; redirect URIs must match exactly; bearer tokens must not go in the URL query; and PKCE belongs in every authorization code flow, not only for mobile apps. OAuth 2.1 is still a draft in the working group's repository, so say "OAuth 2.0 with the current best practices" when you are being precise. Libraries such as Auth.js already use PKCE by default.

Why state and nonce if we have PKCE?

They solve different problems.

ValueWhere it livesAttack it stops
stateIn the redirect URL and a cookieLogin CSRF: an attacker makes your browser finish their login, so you end up logged in as the attacker
PKCE verifierOnly on your server until /tokenCode injection / theft: someone uses a stolen or injected code
nonceInside the ID tokenID token replay: an old ID token is accepted for a new login

PKCE also protects against some CSRF-style attacks, so some servers treat it as a replacement for state. Auth.js uses PKCE by default and adds state where it is needed. Keeping both is cheap and safe. Do not argue that one is "enough" in an interview. Say what each one is for.

Tokens: access, refresh, ID

Access tokenRefresh tokenID token
PurposeCall an APIGet a new access tokenTell the app who logged in
LifetimeMinutes (5 to 60)Days to monthsMinutes; used once at login
Sent toThe resource serverOnly the token endpointNowhere; your app reads it
If stolenWorks until it expiresDangerous: new access tokensLittle use for access
Where to keep itServer memory or an encrypted server-side sessionServer-side only (database or encrypted cookie)Not stored after the user is created

The best place for the provider's tokens is on your server, not in the browser. If your Next.js server calls the GitHub or Google API, store the access and refresh tokens in your database or an encrypted HttpOnly cookie, and give the browser only your own session cookie. This is the "backend for frontend (BFF)" idea from Browser Contract, Lesson 3.

Refresh token rotation and reuse detection

Refresh token rotation means: each time you use a refresh token, the server gives you a new refresh token and cancels the old one. The server remembers which tokens belong together (a family). If an old token appears again, somebody copied it. The server cannot know whether it is the thief or the real user, so it cancels the whole family. The user must log in again, and the thief is out.

JavaScript
// simplified server side (from the script I ran)function tokenFromRefresh(rt) {  const r = refresh.get(rt)  if (!r) throw new Error('invalid_grant')  const fam = families.get(r.familyId)  if (fam.revoked) throw new Error('family revoked')  if (r.used) { fam.revoked = true; throw new Error('reuse detected') }  r.used = true  return { access_token: newAccessToken(), refresh_token: issueRefresh(r.familyId) }}

In my test: refresh once, get a new token; replay the first one, get reuse detected; then even the newest token fails with family revoked.

The trade-off, which the Auth.js docs also warn about: with rotation, two parallel requests that both try to refresh will race. One wins, the other presents an already-used token and may log the user out. On your side, make refresh a single place guarded by a lock, or refresh in the background before expiry.

Validating an ID token

Your server must check the ID token before trusting it. A library does this, but you should know the list:

  1. The signature, using the keys from the issuer's JWKS, and the allowed algorithm (never trust the alg in the token header).
  2. iss equals the issuer you expected.
  3. aud contains your client_id.
  4. exp is in the future (small clock tolerance).
  5. nonce equals the one you stored.
JavaScript
import { jwtVerify, createRemoteJWKSet } from 'jose'const JWKS = createRemoteJWKSet(new URL('https://idp.example/.well-known/jwks.json'))const { payload } = await jwtVerify(idToken, JWKS, {  issuer: 'https://idp.example',  audience: process.env.CLIENT_ID,  algorithms: ['RS256'], // pin it})if (payload.nonce !== storedNonce) throw new Error('bad nonce')

I ran this with a local key set. A token with the wrong audience failed with unexpected "aud" claim value, a wrong issuer failed the same way for iss, and a forged token with alg: none was rejected because only RS256 was allowed. Note that jose does not check the nonce for you. That line is yours.

Choosing: Auth.js, Better Auth, Clerk, Auth0, or your own

A short comparison table is already in Full-Stack Q&A, Lesson 13. This section is about how to decide and what to say.

Auth.js (NextAuth) v5

Auth.js is the long-running open-source library for Next.js (the project started in 2018). You run it inside your app. It handles the OAuth flow, cookies and CSRF, and it can store users in your database through adapters (small packages for Prisma, MongoDB and others).

Three facts that popular tutorials get wrong:

  • Version 5 is still a beta on npm. next-auth@latest is 4.24, and v5 is published as 5.0.0-beta.32. Many tutorials say "use v5" without saying this.
  • The Auth.js README now says the project is part of Better Auth and recommends Better Auth for new projects, except when you need stateless sessions with no database. Better Auth keeps fixing security problems in Auth.js.
  • Passkeys in Auth.js are experimental. The provider needs experimental: { enableWebAuthn: true }.

A v5 setup, type-checked against Next.js 16:

JavaScript
// auth.tsimport NextAuth from 'next-auth'import Google from 'next-auth/providers/google'import GitHub from 'next-auth/providers/github'export const { handlers, auth, signIn, signOut } = NextAuth({  providers: [Google, GitHub], // reads AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET, ...  session: { strategy: 'jwt', maxAge: 60 * 60 * 24 * 30 },  callbacks: {    jwt({ token, user }) {      if (user) token.role = (user as { role?: string }).role ?? 'member' // runs with `user` only at sign-in      return token    },    session({ session, token }) {      session.user.id = token.sub!      return session    },  },})// app/api/auth/[...nextauth]/route.ts// import { handlers } from '@/auth'// export const { GET, POST } = handlers

Defaults worth knowing (from the source): the session strategy is database if you pass an adapter, otherwise jwt; the session lasts 30 days, refreshed at most once a day; the JWT is encrypted in an HttpOnly cookie (cookies over about 4 KB are split into chunks); you need an AUTH_SECRET of at least 32 random characters.

Better Auth (newer)

Maturity check. Better Auth started in 2024, so it is about two years old. It is not a "new unpopular" project: it has a large community, a long release history (1.7 as I write) and the Auth.js team recommends it. But it moves fast, so read the current docs before you copy anything. Say this honestly in an interview.

Better Auth is a TypeScript library that keeps users, accounts and sessions in your own database and adds features as plugins: two-factor, passkeys, organizations (for multi-tenancy), and more.

JavaScript
import { betterAuth } from 'better-auth'import { nextCookies } from 'better-auth/next-js'import { twoFactor, organization } from 'better-auth/plugins'import { passkey } from '@better-auth/passkey'export const auth = betterAuth({  socialProviders: {    github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! },  },  session: {    expiresIn: 60 * 60 * 24 * 7,  // 7 days (the default)    updateAge: 60 * 60 * 24,      // extend at most once a day (the default)    cookieCache: { enabled: true, maxAge: 5 * 60 },  },  account: { accountLinking: { enabled: true, trustedProviders: [] } },  plugins: [twoFactor({ issuer: 'My App' }), organization(), passkey(), nextCookies()],})

Facts from its docs: sessions are database rows and the cookie holds the session token; the cookie cache is a short-lived signed cookie so you skip the database on most reads, with the warning that a revoked session may stay valid on other devices until the cache expires; it protects against CSRF with Origin checks, Fetch Metadata headers and SameSite=Lax; and it uses state and PKCE for OAuth.

Clerk and Auth0: hosted services

Clerk and Auth0 are hosted identity services (IDaaS, identity as a service). They store the users, run the login pages and send the emails. Your app uses their SDK and checks their tokens.

Strong pointsCosts and risks
Hosted (Clerk, Auth0)Fast to ship; MFA, passkeys, SSO, bot protection and compliance work are done for you; no password database to losePrice grows with active users; vendor lock-in (your users and sessions live there); their outage is your outage; custom flows are limited; moving away is hard
Library in your app (Auth.js, Better Auth)Free; your data stays in your database; full controlYou own bugs, upgrades, email delivery, abuse handling and the security review

My rule: use a hosted service when auth is not your product and the team is small, and when enterprise needs (SAML single sign-on, audit logs) are coming. Use a library when you need data ownership, a custom model (like complex tenants) or low cost at scale. Write your own only for learning; the Next.js docs themselves recommend a library.

A hosted service does not remove your work: you still check the session on every server entry point, keep roles and tenants consistent between their data and yours, and plan for the day you need to leave (can you export users, and what happens to password hashes?). I treat the export rules as something to confirm with the vendor, not assume.

Session strategies: database or JWT

You have seen the general difference in Browser Contract, Lesson 2. The library view in one table:

Database sessionJWT (stateless) session
Cookie holdsA random session idThe signed (or encrypted) data itself
Read costOne database or cache lookupNone, just a signature check
Logout everywhere / ban a userDelete the rows; effective at onceNot possible before expiry without a block list
Role changesVisible on the next requestStale until the token is refreshed
Size limitNoneAbout 4 KB per cookie

I ran a small proof: after "logout", a JWT session still verified successfully (it was valid until exp), while a deleted session row returned nothing at once. If you choose JWT, keep it short (minutes) and add refresh, or accept that bans take time. In both Auth.js and Better Auth the cookie-cache idea is the middle path: database truth, with a short signed cache.

Protecting a Next.js 16 app

The Next.js authentication guide gives three layers. Use all three, because each one covers what the others miss.

  1. Optimistic check in proxy.ts (called middleware.ts before Next.js 16). Cheap. It only looks at the cookie and redirects. It is for user experience, not for security.
  2. A data access layer (DAL): one server-only module with getSession() that every Server Component, Server Action and Route Handler calls, close to the data.
  3. Checks in the query: ownership and tenant are part of the database query.

The session helper

JavaScript
// lib/session.tsimport 'server-only'import { cookies } from 'next/headers'import { cache } from 'react'import { jwtVerify, SignJWT } from 'jose'const key = new TextEncoder().encode(process.env.SESSION_SECRET)export type Session = { userId: string; role: 'member' | 'admin'; orgId: string }export async function createSession(s: Session) {  const token = await new SignJWT(s)    .setProtectedHeader({ alg: 'HS256' })    .setIssuedAt()    .setExpirationTime('15m')    .sign(key)  ;(await cookies()).set('session', token, {    httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 15 * 60,  })}// cache() = one lookup per request, shared by layout, page and actionsexport const getSession = cache(async (): Promise<Session | null> => {  const token = (await cookies()).get('session')?.value  if (!token) return null  try {    const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] })    return payload as unknown as Session  } catch {    return null  }})

cache from React remembers (memoizes) the result for one request only, so five components can call getSession() and the cookie is verified once. In Next.js 16, cookies() is async, so you await it.

The proxy

JavaScript
// proxy.tsimport { NextRequest, NextResponse } from 'next/server'// Optimistic check only: is there a session cookie at all? No database here.export function proxy(request: NextRequest) {  const hasCookie = request.cookies.has('session')  if (!hasCookie && request.nextUrl.pathname.startsWith('/dashboard')) {    const url = new URL('/login', request.url)    url.searchParams.set('next', request.nextUrl.pathname)    return NextResponse.redirect(url)  }  return NextResponse.next()}export const config = {  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],}

Facts from the Next.js 16 docs: the file exports a function named proxy (or a default export); it runs on the Node.js runtime; without a matcher it runs on every request including static files, so exclude them; and it also runs on prefetches, so do not call the database in it.

Server Actions and Route Handlers

Treat both as public HTTP endpoints. Anyone can send a request to them with curl, no matter what your UI shows.

JavaScript
// app/actions/orders.ts'use server'import { getSession } from '@/lib/session'import { db } from '@/lib/db'export async function cancelOrder(orderId: string) {  const session = await getSession()  if (!session) throw new Error('UNAUTHENTICATED')  // ownership and tenant are part of the query, not an `if` after it  const result = await db.order.updateMany({    where: {      id: orderId,      orgId: session.orgId,      ...(session.role === 'admin' ? {} : { userId: session.userId }),    },    data: { status: 'cancelled' },  })  if (result.count === 0) throw new Error('NOT_FOUND') // hides other tenants' ids}
JavaScript
// app/api/orders/[id]/route.tsimport { getSession } from '@/lib/session'import { db } from '@/lib/db'export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {  const session = await getSession()  if (!session) return Response.json({ error: 'unauthenticated' }, { status: 401 })  const { id } = await ctx.params  const order = await db.order.findFirst({ where: { id, orgId: session.orgId } })  if (!order) return Response.json({ error: 'not found' }, { status: 404 })  return Response.json(order)}

Both compiled in strict TypeScript against Next.js 16. Notes:

  • In a Route Handler you return 401 / 404 yourself. Next.js also has unauthorized() and forbidden() helpers, but they are experimental and need experimental.authInterrupts: true. Do not build on them without saying so.
  • Server Actions get a built-in CSRF check: Next.js compares the Origin header with Host (or X-Forwarded-Host) and only accepts POST. Behind a proxy or CDN on another domain, list it in serverActions.allowedOrigins.
  • A Server Action is a POST to the page's own URL. If your proxy matcher skips that path, the proxy also skips the action. So never rely on the proxy for actions.

Do not make proxy.ts (or any layout) your only check. In March 2025 a bug let attackers skip Next.js middleware by sending a special header; it was fixed in patch releases. The deeper lesson holds without any bug: layouts do not re-render on every navigation, and a page, an action and a route handler are separate entry points. Check in the data layer.

A word about Cache Components

If you turn on Cache Components, reading the session at request time cannot be part of the static page shell. Put the part of the page that reads cookies() inside <Suspense>. For caching per-user data, the docs use 'use cache: private', which can read cookies and keeps the result in the browser only. Never put user data into a shared cache without the user id as part of the key.

RBAC, ABAC and multi-tenant authorization

Backend, Lesson 6 has the first look at RBAC and ABAC. The deeper points:

  • RBAC is simple to understand and audit: admin, editor, viewer. It breaks when you need rules like "an editor can edit only drafts in their own team". Roles explode (editor-team-a, editor-team-b).
  • ABAC answers with a rule over attributes: user.orgId == doc.orgId && (user.role == 'admin' || doc.ownerId == user.id). It is flexible, but harder to audit, so keep rules in one function and test them with a table of cases.
  • In practice, I use RBAC for coarse feature access and ABAC-style checks for per-record decisions. The result is one function, can(user, action, resource), instead of if statements spread over the code.

Multi-tenant isolation

In a multi-tenant app the worst bug is one customer seeing another customer's data. The rules:

  1. The tenant id comes from the verified session (or the verified membership), never from a request body, query string or a header the client controls.
  2. Every query includes the tenant filter. Wrap it so people cannot forget: a repository function forTenant(orgId) or a Prisma client extension that adds where: { orgId }. For stronger safety, use PostgreSQL row-level security (RLS), where the database itself refuses cross-tenant rows.
  3. A user can belong to many tenants. Keep a current organization in the session, and check the user's membership and role in that organization on every request, not a global role.
  4. Return 404 for another tenant's id, so ids do not leak existence.
  5. Test it: a test that logs in as tenant A and requests tenant B's id for every endpoint.

Subdomain-based tenants (acme.example.com) need the cookie scope thought out: a cookie set for .example.com is sent to all tenants' subdomains. Better Auth and Auth.js both let you configure cookie domains, but shared cookies widen the blast radius of an XSS bug on any one subdomain. The Next.js docs only point to a "platforms starter kit" for this topic, so treat it as an architecture decision you own.

Passkeys and MFA

Passkeys (WebAuthn)

WebAuthn is the browser standard behind passkeys. When the user registers, their device makes a key pair for your site. The private key stays on the device (often protected by a fingerprint or face check). Your server stores the public key. At login your server sends a random challenge, the device signs it, and your server checks the signature with the public key.

Why this is strong:

  • There is no shared secret on your server. A database leak gives attackers only public keys.
  • The credential is tied to your domain (the relying party id, rpID). A fake site on another domain cannot ask for it, so phishing does not work.
  • Many passkeys sync across the user's devices through the platform account (Apple, Google, Microsoft, password managers). So losing one phone is not losing the account.

The library @simplewebauthn/server makes the options for you:

JavaScript
import { generateRegistrationOptions } from '@simplewebauthn/server'const options = await generateRegistrationOptions({  rpName: 'My App', rpID: 'example.com', userName: 'ada@example.com',})// keys: challenge, rp, user, pubKeyCredParams, timeout (60000), attestation ('none'),//       excludeCredentials, authenticatorSelection (residentKey 'preferred'), ...

You must save the challenge on the server for the user's session, and check it when the browser sends the signed result back. Using the same challenge twice, or not checking it, is the classic mistake. In Better Auth, the passkey plugin does this and supports browser autofill ("conditional UI": the passkey appears in the username field). Remember rpID and origin must match your real domain, and localhost needs explicit settings.

Always keep a recovery path (another passkey, an email link, backup codes). Without it, a lost device becomes a support ticket.

MFA and TOTP

MFA combines different factors: something you know (password), something you have (phone, security key), something you are (fingerprint). Two passwords are not two factors.

MethodStrengthWeak point
SMS codeBetter than nothingSIM swap, phishing, interception
TOTP appGood; works offlinePhishable (a fake site can ask for the code)
Passkey / security keyBest; phishing-resistantNeeds recovery planning
Backup codesRecovery onlyMust be stored hashed, single use

TOTP is simple: the server and the app share a secret; both compute HMAC(secret, floor(time / 30)) and take six digits from the result. I wrote it by hand (about ten lines with node:crypto) and it matched the RFC 6238 test value 94287082 for time 59 with 8 digits. In production use a library (Better Auth's twoFactor plugin, or otplib), accept one step before and after for clock drift, rate limit attempts, and never accept the same code twice.

CSRF with OAuth state, and account linking

Login CSRF and state

In a login CSRF attack, the attacker starts an OAuth login with their own account and gets a code. They trick your victim's browser into opening the callback URL with that code. Without state, your app finishes the login and the victim is now logged in as the attacker. They may then save their card or files into the attacker's account.

state fixes it. Your app puts a random value in a cookie when it starts the flow and sends the same value to the provider. At the callback, the value from the URL must equal the cookie. The attacker cannot set a cookie in the victim's browser, so the check fails. Compare it in constant time and expire it after a few minutes. Libraries do this for you; Auth.js and Better Auth both store it in a short-lived cookie. The general CSRF defences for your own endpoints are in Browser Contract, Lesson 3.

Also check the redirect_uri on the provider side by exact match, and validate any callbackUrl or next parameter in your own app, so it cannot become an open redirect (a link on your domain that sends the user to an attacker's site).

Account linking

Account linking means attaching a second login method (Google, GitHub, password) to the same user. The risk is account takeover by email: an attacker creates an account on a weak provider with your victim's email and, if your app links by email without checking, takes over the existing account.

  • Auth.js refuses by default. If another account has the same email, the user gets an OAuthAccountNotLinked error. The flag allowDangerousEmailAccountLinking turns on automatic linking, and its name is a warning. Use it only for providers that you know verify email.
  • Better Auth links automatically only if the provider reports the email as verified (or the provider is in trustedProviders). disableImplicitLinking: true turns off linking on sign-in; then users link from their settings page while logged in with linkSocial.
  • The safe rule: link on sign-in only when the email is verified by a provider you trust; otherwise ask the user to log in with the old method first and link from the settings page. Also, when the email is unverified on the existing account, an attacker may have pre-registered it, so do not trust it.

Common mistakes

MistakeFix
Implicit flow or password grant for a new appAuthorization code with PKCE
Treating the ID token as an API credentialID token: for the app. Access token: for the API
Not pinning algorithms or not checking aud/iss/nonceVerify all of them; let a maintained library do it
Tokens from the provider in localStorageKeep them on the server (BFF)
Auth only in proxy.ts or a layoutCheck in the DAL, each action, each handler, and in the query
Tenant or user id read from the request bodyRead it from the verified session
Long-lived JWT session with no way to revokeShort expiry plus refresh, or a database session
Auto-linking accounts by emailLink only for verified emails, or from the settings page
Refresh calls racing in parallelOne locked refresh path
No recovery for passkeys / MFABackup codes and a second method
Following "use NextAuth v5" tutorials blindlyCheck the latest tag and the README before starting

Interview questions

What is the difference between OAuth 2.0 and OpenID Connect?

Must know. OAuth 2.0 is about authorization: it lets an app get limited access to something, like reading a user's calendar, with a token. It says nothing standard about who the user is. OpenID Connect is a thin layer on top that adds authentication: an ID token (a signed JWT) with claims like sub, iss, aud and nonce, plus a userinfo endpoint. When I build "Sign in with Google" I am using OIDC, even if people call it OAuth. In one line: OAuth answers "what may this app do?", OIDC answers "who is this person?". The tokens go to different places: the access token goes to the API, and the ID token is read only by my app.

Walk me through the authorization code flow with PKCE.

Must know. My server makes a random verifier and sends only its SHA-256 hash, the challenge, along with state, nonce, the scopes and the exact redirect_uri, in a redirect to the provider. The user logs in and approves. The provider redirects back with a one-time code and my state. I check that state matches the cookie I set. Then, from my server, I call the token endpoint with the code and the verifier, plus my client secret if I have one. The provider hashes the verifier and compares it with the challenge, and returns the tokens. Then I validate the ID token and create my own session cookie. The point of PKCE: the code goes through the browser and can leak, but it is useless without the verifier, which never left my server.

Why is PKCE needed? Is it only for mobile apps and SPAs?

Must know / trap. No, that is outdated advice. It was invented for public clients (mobile apps) that cannot keep a secret, but the current security guidance and the OAuth 2.1 draft want PKCE in every authorization code flow, including server apps with a client secret. It stops an attacker who stole or injected an authorization code, because they do not have the verifier. It costs a few lines, and libraries like Auth.js turn it on by default. The old alternatives are worse: the implicit flow puts tokens in the URL and is no longer recommended.

What are state and nonce for? Doesn't PKCE make them redundant?

Must know → senior signal. They solve different problems. state ties the callback to a login that this browser started, so it stops login CSRF, where an attacker forces my browser to complete their login. nonce is a value that comes back inside the ID token, so a captured old ID token cannot be replayed into a new login. PKCE protects the code exchange. PKCE does cover some of the same attacks as state, which is why some servers say it can replace it, but the extra checks are cheap, so I keep each for its purpose. I also remember that the library checks state but I must check nonce myself if I use a low-level library like jose.

Access token, refresh token, ID token: what is each for, and where do you store them?

Must know. The access token is short-lived and is sent to the API. The refresh token is long-lived and only goes to the token endpoint to get new access tokens. The ID token tells my app who logged in, and I use it once. For web apps I keep provider tokens on the server: in my database or an encrypted HttpOnly cookie, and the browser only has my own session cookie. This is the backend-for-frontend pattern, and it means XSS cannot read the tokens. The trade-off is that my server needs to refresh the access token when it expires, and that makes my server stateful or at least cookie-heavy.

What is refresh token rotation, and what happens when a rotated token is used twice?

Senior signal. Each refresh returns a new refresh token and invalidates the old one. The server keeps a family of tokens that came from one login. If an old token comes back, one of two parties has a copy, the real client or a thief. The server cannot tell which, so it revokes the whole family, and the user logs in again. I tested this: refresh once works, replaying the first token gives "reuse detected", then even the newest token is dead. The gotcha is a race: two tabs or two server requests refreshing at the same time can look like reuse and log the user out. I serialize refresh with a lock, or refresh a little before expiry in one place.

What must you check when you receive an ID token?

Must know / trap. Signature against the issuer's JWKS with a pinned algorithm, iss equals the expected issuer, aud contains my client id, exp is in the future, and nonce matches the one I stored. The traps: trusting the alg in the header (the alg: none forgery), skipping aud (a token meant for another app is accepted), and forgetting nonce, because jose checks the first four options I pass but not the nonce. I ran these cases: wrong audience, wrong issuer and alg: none were all rejected. I also never use the ID token to call APIs.

Auth.js or Better Auth for a new Next.js 16 project? And what about v5?

Senior signal. I check the facts first. Auth.js v5 has been in beta for a long time: on npm latest is 4.24 and v5 is 5.0.0-beta.32. The Auth.js README now says it is part of Better Auth and recommends Better Auth for new projects, except where you need stateless sessions with no database at all. Better Auth is younger, about two years, but it has a big community, regular releases, and plugins for two-factor, passkeys and organizations. So for a new project with a database I would choose Better Auth, and I would still pin its version and read the changelog on upgrades. For an existing app on Auth.js I would not migrate for the sake of it; the team says security patches continue, and a migration guide exists for when it is worth it. If auth is not core to the product and the team is small, I would also consider Clerk or Auth0.

Hosted (Clerk, Auth0) versus a library in your own app. How do you choose?

Senior signal. Hosted: I ship in days, and MFA, passkeys, social login, SSO and compliance work come ready, and I do not hold a password database. The costs are price that grows with active users, vendor lock-in, dependence on their uptime, and less freedom for unusual flows. Library: free, my data in my database, full control, but I own bugs, upgrades, email deliverability, abuse protection and the security review. I choose hosted for a small team, a B2B product that will need enterprise SSO soon, or an MVP. I choose a library when the data model is special (complex tenants), the user count is large, or data ownership is a requirement. In both cases I plan the exit: can I export users, and what happens to password hashes? I confirm that with the vendor rather than assume.

Database sessions or JWT sessions? Which would you pick, and how would you revoke access?

Must know → senior signal. Database sessions: the cookie has a random id, I look it up, and deleting the row logs the user out at once, so "log out of all devices" and banning are easy. The cost is a lookup per request, which a cache fixes. JWT sessions: no lookup, but I cannot take a token back before exp unless I keep a block list, and that makes it stateful again. Roles inside the token are stale until refresh. Also, cookies have a limit of about 4 KB. So for a normal product I choose database sessions with a short cookie cache. If I need JWT (many services, edge reads), I use a short expiry such as 15 minutes plus a refresh, and I accept that a ban takes up to that long. The cookie cache has the same trade-off: revoked sessions can stay valid until the cache expires.

How do you protect routes, Server Actions and Route Handlers in Next.js 16?

Must know. I use layers. proxy.ts (the new name for middleware) does a cheap optimistic check: is there a session cookie? If not, redirect to login. It does no database work, because it runs on every request and prefetch. Then a server-only data access layer has getSession() wrapped in React cache, and every Server Component, Server Action and Route Handler calls it. In the query itself I include user and tenant, so a wrong id returns nothing. The key rule from the docs: Server Actions and Route Handlers are public endpoints, so each must check by itself. A layout check is not enough, because layouts do not re-render on every navigation and an action can be called without any page.

Why is checking auth only in the proxy or middleware a bug?

Trap. There are three reasons. First, a proxy matcher is easy to get wrong. It can skip static-looking paths, and a Server Action is a POST to the page's own URL, so if a refactor moves it or the matcher excludes that path, the action loses its protection without any error. Second, the proxy only has the cookie, so it cannot check ownership of a record. Third, bugs happen: in 2025 a Next.js middleware bypass showed that a single layer can be skipped. The fix is defence in depth: the proxy is for user experience, and the real checks sit next to the data.

RBAC or ABAC? How do you model permissions in a real SaaS?

Senior signal. I start with RBAC for coarse access, with roles like owner, admin, member and viewer, because it is easy to explain and to audit. When I need "an editor can edit drafts only in their own team", roles multiply, and I add ABAC-style rules over attributes: the user's role and team, the record's owner and status. I keep it all in one can(user, action, resource) function with a table of tests, so rules are not spread across handlers. The trade-off: ABAC is flexible but harder to audit ("who can see this?"), so I log decisions for sensitive actions. And the final enforcement is in the query, not in a UI that hides a button.

Design tenant isolation for a multi-tenant Next.js app. What goes wrong most often?

Senior signal. The tenant id comes only from the verified session or membership, never from the request body or a header. A user can belong to many organizations, so the session holds the current organization and I check membership and role for that organization on each request. Every query has the tenant filter, and I make it hard to forget with a repository function or a Prisma client extension. For stronger safety I add PostgreSQL row-level security. Another tenant's id returns 404. The usual failures: an endpoint that takes orgId from the body, a background job that runs without a tenant, a cache key without the tenant id, and a global role used where an organization role is needed. I test by logging in as tenant A and trying tenant B's ids on every route.

How do passkeys work, and why are they phishing-resistant?

Must know → senior signal. At sign-up the device creates a key pair for my domain (the rpID). The private key stays on the device, usually behind a fingerprint or face check; I store the public key. At login I send a random challenge, the device signs it, and I verify the signature with the public key, and I check the challenge, the origin and the rpID. There is no shared secret to steal from my database. Because the credential is bound to my domain, a fake site on another domain cannot get a valid signature, so phishing fails. Many passkeys sync between a user's devices. Gotchas: store the challenge per attempt and use it once, set rpID and the allowed origin correctly (including for local development), and always provide recovery. In Auth.js the passkey provider is still marked experimental; in Better Auth it is a plugin with autofill support.

Add MFA to an app. Which methods, and what do you watch out for?

Must know. I offer TOTP (an authenticator app) and passkeys, and keep SMS only as a weak fallback, because of SIM swaps. TOTP is HMAC of a shared secret and the current 30-second window; I tested a hand-written version against the RFC value. Production rules: show the secret once as a QR code and require the user to enter a valid code before enabling it; accept one step of drift; rate limit attempts; never accept the same code twice; store the secret encrypted; give single-use backup codes stored hashed. Also decide when to ask: at every login, or only on a new device or for sensitive actions ("step-up"). Be honest that TOTP can be phished by a real-time fake site; passkeys cannot.

How does state protect against CSRF in an OAuth login, and what other redirect problems should you look for?

Trap / gotcha. Without state, an attacker can start a login with their own account, get a code, and trick my user's browser into opening my callback URL with it. My app would log my user in as the attacker. With state, I put a random value in a cookie and send it to the provider; at the callback the URL value must equal the cookie. The attacker cannot set my user's cookie, so it fails. Related checks: the provider must compare the redirect_uri by exact match, and my own callbackUrl or next parameter must be checked against an allow list so it is not an open redirect. And the usual CSRF protections, like SameSite cookies and Origin checks, still apply to my other endpoints.

A user signs in with Google using an email that already has a password account. What do you do?

Trap / senior signal. I do not link blindly. The danger is account takeover: an attacker could register at a weaker provider with the victim's email. Auth.js blocks this by default with OAuthAccountNotLinked, and the flag allowDangerousEmailAccountLinking is named that way for a reason. Better Auth links automatically only when the provider says the email is verified or the provider is in trustedProviders, and disableImplicitLinking turns it off. My rule: automatic link only when the email is verified by a provider I trust; and for the existing account, I also check that its email was verified, because an attacker may have pre-registered it. Otherwise, I ask the user to log in with the old method first and link Google from the settings page.

Your Next.js login works locally but fails on the staging server with a redirect or callback error. Where do you look?

Debugging. I check in this order. One: the exact redirect_uri registered at the provider, because providers compare exactly, including scheme, host, port and trailing path. Two: the base URL the app believes it has. Behind a reverse proxy or Docker, Auth.js needs AUTH_TRUST_HOST (or trustHost) and correct forwarded host headers, and Better Auth needs the right base URL and trustedOrigins. Three: AUTH_SECRET (or the Better Auth secret) must be the same on all instances, otherwise one instance cannot read a cookie another one wrote; the same applies to preview deployments. Four: cookies. Secure needs HTTPS, a cookie domain that does not match the host is dropped, and SameSite can block a cookie on the callback. Five: the Server Action Origin versus Host check, if the failing call is an action behind a proxy; then set serverActions.allowedOrigins. Six: the provider's clock and mine, for the ID token exp.

Interviewer: "We use Clerk (or Auth0). So we do not need to think about authorization in our code, right?"

Trap. Wrong. A hosted service gives me authentication: who the user is, MFA, social login. It does not know my data. I still verify the session or token on the server at every entry point, load the user's role and organization, and put the user and tenant into every query. I also decide where the role lives, in their metadata or in my database, and how fast changes reach my app, because a role in a token is stale until refresh. And I protect Server Actions and Route Handlers, which are public endpoints even with a perfect login page.

Key takeaways

  • OAuth gives limited access with tokens; OIDC adds "who logged in" with an ID token. Use the authorization code flow with PKCE; the implicit and password grants are out.
  • state stops login CSRF, PKCE stops code theft, nonce stops ID token replay. Validate iss, aud, exp, signature with a pinned algorithm, and nonce.
  • Keep provider tokens on the server. Use refresh token rotation with reuse detection, and lock your refresh calls.
  • Database sessions can be revoked at once; JWT sessions cannot. A short cookie cache is the middle road.
  • Auth.js v5 is still a beta and its README recommends Better Auth for new projects; Better Auth is newer but established enough to use with care. Hosted services trade money and lock-in for speed.
  • In Next.js 16: proxy.ts is an optimistic check only; real checks live in a server-only DAL, in every Server Action and Route Handler, and inside the query.
  • Multi-tenant: tenant from the session, in every query, 404 for other tenants, and test it.
  • Passkeys are phishing-resistant; TOTP is good but phishable; always have recovery. Link accounts only on verified emails.

What's next

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series