Quiz - The auth mental model
A reviewer flags this layout, which renders a settings page only when the user’s role is admin:
export default async function SettingsLayout({ children }) { const user = await getUser(); if (user?.role !== 'admin') return <NotAllowed />; return <>{children}</>;}Why is this the wrong place for that check?
It’s an authorization check placed at a render boundary, not the action boundary. A layout can be skipped under partial pre-rendering, and hiding UI stops nobody who POSTs to the action directly — the gate has to live on the server-side mutation.
The check itself is correct, but it should compare against 'member' instead of 'admin' — layouts are the right home for role gates, the role is just wrong.
A layout can only run authentication checks, so it should return <NotAllowed /> whenever user is null and let the page component handle the role.
A signed-in user requests invoice inv_42, which genuinely exists but belongs to a different organization. The clean rule says authn-fail is 401 and authz-fail is 403. What’s the safest status to return here, and why does it bend the rule?
404 Not Found — a 403 would confirm that inv_42 exists but isn’t theirs, leaking one tenant’s data to another. Masking cross-tenant access as “not found” keeps other organizations’ records invisible.
403 Forbidden — the identity is proven and the action is refused, so the textbook authz-fail code is exactly right and there’s no reason to hide anything.
401 Unauthorized — the user shouldn’t see another org’s data, so treating them as unauthenticated and bouncing them to sign-in is the safe default.
401/403 split. A 403 is technically honest — known, not allowed — but it quietly confirms inv_42 is a real record, which leaks the existence of another tenant’s data across an organization boundary. Returning 404 keeps other tenants’ records invisible. 401 would be wrong too: the user is fully authenticated, signing in again changes nothing.Why does forcing a fresh password prompt before a user changes their billing details count as authentication triggered by an authorization policy, rather than just one or the other?
The rule “this capability needs recent proof” is an authorization policy; the re-prompt it fires — checking a password or passkey again — is authentication. The two concerns hand off to each other rather than being the same thing.
It’s pure authorization: re-prompting is just a stricter permission check, and no new authentication happens because the user is already signed in.
It’s pure authentication: the session already expired, so the user is simply being asked to sign in again from scratch.
A teammate argues for switching the browser session to a JWT because “stateless is modern and skips a database read per request.” For a typical Next.js SaaS, what’s the single property that makes this the wrong default?
Revocation. “Sign out everywhere,” killing a compromised account, and stopping a stolen cookie are each one DELETE with server-stored sessions, but impossible with a pure JWT until it expires — and adding a denylist to fix that re-introduces the per-request read the JWT was chosen to avoid.
Payload size. A JWT carries all its claims in the cookie, so it exceeds the browser’s cookie size limit once you add device and org metadata, which forces a switch back to sessions.
Confidentiality. A JWT’s payload is encrypted, so the server can’t read the user ID without the signing secret, making per-request identity reads slower than an indexed lookup.
exp no matter what. Bolt on a denylist to get revocation back and you’ve re-added the per-request database read JWTs were supposed to escape, while running two systems instead of one. (The payload isn’t encrypted, just signed — base64url is reversible — so the confidentiality option is also wrong.)A hand-rolled auth path stores user data in the cookie and trusts it at request time. Pick both of the choices below that are genuine bugs in that approach.
Comparing the presented session token to the stored one with === leaks length-of-match through timing; a constant-time compare that examines every byte is required.
Putting the user’s role in the cookie and trusting it at the action boundary lets a stale cookie act with capabilities the server already revoked; the boundary must re-read role from the database.
Generating the session token with crypto.randomUUID() is too weak; only a 64-byte token from crypto.getRandomValues() clears the entropy bar.
Reusing the same session row across a user’s laptop and phone is required so “sign out everywhere” can delete a single row.
=== comparison short-circuits on the first mismatched byte, so response timing reveals how much of the token was correct — a constant-time compare is the fix. And trusting a role baked into the cookie lets a stale cookie keep capabilities after a server-side change, which is why the action boundary always re-reads authz from the source of truth. The distractors invert facts: crypto.randomUUID() (122 bits) clears the 128-bit-ballpark bar fine, and each device should get its own row — that’s what makes “sign out everywhere” a DELETE WHERE userId = ? and powers the active-sessions list.Naming the auth cookie __Host-session rather than session does what, exactly?
The browser refuses to store the cookie unless Secure and Path=/ are set and no Domain= is present — turning “is this cookie scoped tightly?” from something the developer must remember into something the platform enforces.
It tells the server to keep the session record in memory on the host rather than in the database, which is what makes the per-request lookup fast.
It encrypts the cookie value with a host-bound key so that document.cookie returns ciphertext instead of the raw token.
__Host- prefix is a contract the browser enforces: it rejects the cookie on Set-Cookie unless Secure and Path=/ are present and Domain= is absent. That moves tight scoping from a convention you hope holds to a guarantee the platform makes — a misscoped auth cookie simply never gets stored. It has nothing to do with where the session record lives, and the prefix doesn’t encrypt anything; hiding the value from JavaScript is HttpOnly’s job.In a “Sign in with Google” flow, which sentence captures the relationship between OAuth and OpenID Connect (OIDC)?
OAuth is an authorization protocol that hands the app tokens (“this app may act on the user’s behalf”); OIDC is a layer on top, opted into with the openid scope, that adds the id_token so the app learns who the user is.
OIDC is the authorization protocol and OAuth is the authentication layer built on top of it; you request the oauth scope to get the user’s identity back.
They’re two names for the same protocol; “OIDC” is just what the flow is called once a client_secret is involved.
openid scope and the provider returns an id_token and standardizes userinfo, so the app can read identity. The senior shorthand is “OIDC over OAuth” — authentication standing on the shoulders of an authorization protocol.A new hire on a confidential, server-side app with a client_secret proposes skipping PKCE, reasoning “the secret already proves it’s us.” In OAuth 2.1 (draft-15, 2026), what’s the correct response?
PKCE is mandatory for every client in 2.1, secret or not. A static secret can’t bind one specific code to one specific flow, so it doesn’t stop a stolen code from being replayed — the verifier-and-challenge pair does.
They’re right — PKCE was only ever for public clients (SPAs and mobile) that can’t hold a secret, so a confidential server app can safely turn it off.
PKCE can be skipped as long as the client_secret is rotated per environment, since a unique secret per deployment already binds the flow.
client_secret can’t bind this code to this flow — and secrets get shared across deployments, codes get replayed against staging, and code-injection variants slip in before the secret matters. PKCE closes all of that; per-environment secrets are good hygiene but don’t replace it.During the authorization-code-with-PKCE flow, which value travels on the front channel (through the browser), and which stays off it?
The code_challenge (the SHA-256 hash of the verifier) and the one-time code ride the front channel; the code_verifier and client_secret travel only on the back-channel token exchange.
The code_verifier and client_secret ride the front channel so the provider can validate them in the redirect; the code is returned only on the back channel.
Everything travels the front channel — the back channel only exists for the final userinfo call after the user is already signed in.
code_challenge (a one-way hash that leaks nothing about the verifier) and the short-lived code. The secrets — the code_verifier and the client_secret — travel only on the direct server-to-server back channel during the token exchange. That split is the whole reason a leaked code is survivable: without the verifier, it can’t be redeemed.After verifying the id_token and reading the user’s email, what should the app do with the access_token and refresh_token for a pure login flow — and what is the app’s actual session?
The provider’s tokens were just the proof-of-identity input; for a pure login the app can discard them, then mint its own __Host- session cookie. The OAuth tokens are never the app’s session.
The app should store the access_token in the session cookie and present it on every request — it is the session, which is why OAuth removes the need for a separate cookie.
The app should keep the refresh_token in the browser so it can silently re-run the OAuth flow on each navigation and avoid issuing its own session.
__Host- session cookie — the same hardened cookie from the sessions lesson. For a pure login the access and refresh tokens can be thrown away; you only keep a refresh token (server-side, never in the browser) if the app needs to call the provider’s APIs later without the user present.Quiz complete
Score by topic