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.
On this page
- Words used in this lesson
- OAuth versus OIDC
- The authorization code flow with PKCE
- Tokens: access, refresh, ID
- Choosing: Auth.js, Better Auth, Clerk, Auth0, or your own
- Session strategies: database or JWT
- Protecting a Next.js 16 app
- RBAC, ABAC and multi-tenant authorization
- Passkeys and MFA
- CSRF with OAuth state, and account linking
- Common mistakes
- Interview questions
- Key takeaways
- 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.
stateis 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.nonceis 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), andexp(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 emailorrepo: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.0 | OpenID Connect | |
|---|---|---|
| Question it answers | What may this app do? | Who is this user? |
| Main token | Access token | ID token (a JWT) |
| Who reads the main token | The 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 chooses | Yes: 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".
- Your server creates a random verifier, hashes it into a challenge, and also creates
state(and anoncefor OIDC). It stores them (in a short-lived,HttpOnlycookie or a server store) and redirects the browser to the provider's/authorizeURL withresponse_type=code,client_id,redirect_uri,scope,state,code_challengeandcode_challenge_method=S256. - The user logs in at the provider and approves the scopes.
- The provider redirects the browser back to your
redirect_uriwith?code=...&state=.... - Your server checks that
statematches the one it stored. Then it calls the provider's/tokenendpoint directly (server to server) with thecode, the verifier and its client credentials. - 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.
- 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:
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 /tokenI 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.
| Value | Where it lives | Attack it stops |
|---|---|---|
state | In the redirect URL and a cookie | Login CSRF: an attacker makes your browser finish their login, so you end up logged in as the attacker |
| PKCE verifier | Only on your server until /token | Code injection / theft: someone uses a stolen or injected code |
nonce | Inside the ID token | ID 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 token | Refresh token | ID token | |
|---|---|---|---|
| Purpose | Call an API | Get a new access token | Tell the app who logged in |
| Lifetime | Minutes (5 to 60) | Days to months | Minutes; used once at login |
| Sent to | The resource server | Only the token endpoint | Nowhere; your app reads it |
| If stolen | Works until it expires | Dangerous: new access tokens | Little use for access |
| Where to keep it | Server memory or an encrypted server-side session | Server-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.
// 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:
- The signature, using the keys from the issuer's JWKS, and the allowed algorithm (never trust the
algin the token header). issequals the issuer you expected.audcontains yourclient_id.expis in the future (small clock tolerance).nonceequals the one you stored.
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@latestis 4.24, and v5 is published as5.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:
// 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 } = handlersDefaults 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.
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 points | Costs and risks | |
|---|---|---|
| Hosted (Clerk, Auth0) | Fast to ship; MFA, passkeys, SSO, bot protection and compliance work are done for you; no password database to lose | Price 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 control | You 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 session | JWT (stateless) session | |
|---|---|---|
| Cookie holds | A random session id | The signed (or encrypted) data itself |
| Read cost | One database or cache lookup | None, just a signature check |
| Logout everywhere / ban a user | Delete the rows; effective at once | Not possible before expiry without a block list |
| Role changes | Visible on the next request | Stale until the token is refreshed |
| Size limit | None | About 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.
- Optimistic check in
proxy.ts(calledmiddleware.tsbefore Next.js 16). Cheap. It only looks at the cookie and redirects. It is for user experience, not for security. - 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. - Checks in the query: ownership and tenant are part of the database query.
The session helper
// 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
// 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.
// 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}// 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/404yourself. Next.js also hasunauthorized()andforbidden()helpers, but they are experimental and needexperimental.authInterrupts: true. Do not build on them without saying so. - Server Actions get a built-in CSRF check: Next.js compares the
Originheader withHost(orX-Forwarded-Host) and only acceptsPOST. Behind a proxy or CDN on another domain, list it inserverActions.allowedOrigins. - A Server Action is a
POSTto the page's own URL. If your proxymatcherskips 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 ofifstatements 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:
- 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.
- Every query includes the tenant filter. Wrap it so people cannot forget: a repository function
forTenant(orgId)or a Prisma client extension that addswhere: { orgId }. For stronger safety, use PostgreSQL row-level security (RLS), where the database itself refuses cross-tenant rows. - 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.
- Return
404for another tenant's id, so ids do not leak existence. - 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:
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.
| Method | Strength | Weak point |
|---|---|---|
| SMS code | Better than nothing | SIM swap, phishing, interception |
| TOTP app | Good; works offline | Phishable (a fake site can ask for the code) |
| Passkey / security key | Best; phishing-resistant | Needs recovery planning |
| Backup codes | Recovery only | Must 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
OAuthAccountNotLinkederror. The flagallowDangerousEmailAccountLinkingturns 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: trueturns off linking on sign-in; then users link from their settings page while logged in withlinkSocial. - 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
| Mistake | Fix |
|---|---|
| Implicit flow or password grant for a new app | Authorization code with PKCE |
| Treating the ID token as an API credential | ID token: for the app. Access token: for the API |
Not pinning algorithms or not checking aud/iss/nonce | Verify all of them; let a maintained library do it |
Tokens from the provider in localStorage | Keep them on the server (BFF) |
Auth only in proxy.ts or a layout | Check in the DAL, each action, each handler, and in the query |
| Tenant or user id read from the request body | Read it from the verified session |
| Long-lived JWT session with no way to revoke | Short expiry plus refresh, or a database session |
| Auto-linking accounts by email | Link only for verified emails, or from the settings page |
| Refresh calls racing in parallel | One locked refresh path |
| No recovery for passkeys / MFA | Backup codes and a second method |
| Following "use NextAuth v5" tutorials blindly | Check 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.
statestops login CSRF, PKCE stops code theft,noncestops ID token replay. Validateiss,aud,exp, signature with a pinned algorithm, andnonce.- 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.tsis 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,
404for 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
- Browser Contract, Lessons 2 to 4 for session storage, CSRF and CORS in depth.
- Backend, Lesson 6 for password storage, ownership checks and the first look at RBAC and ABAC.
- Full-Stack Q&A, Lesson 13 for the wider library comparison.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- API Authentication and Authorization: API Keys, OAuth 2.0 and Scopes · API Design
- Authentication and Authorization on the Server · Backend in Depth
- Sessions vs JWT: Stateful vs Stateless Login · The Browser Contract
Part of the study guide
Related series