This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
Two small things about the Auth0 flow had been quietly wrong. Logging in from anywhere dumped me on the home page instead of the route I started on, and declining the consent screen threw a bare 500 instead of letting me back into the app. Both turned out to be one-line defaults in the SDK that I’d never overridden, and both were fixable at a single choke point rather than across every login link.
The site has login links everywhere — the header menu, the landing hero and footer, the v2/v3/v4 hubs, the flags console. Every one of them points at a bare /auth/login. That works, but @auth0/nextjs-auth0 defaults the post-login redirect to / when no returnTo is given. So no matter where I signed in from, I’d land on the home page and have to navigate back.
The second one was worse. Declining the Auth0 consent screen sends the callback back with error=access_denied, and the SDK’s defaultOnCallback answers any callback error with new NextResponse(error.message, { status: 500 }). A bare 500 page, no way back into the app.
I could have added a returnTo to all ten links, but that’s ten edits and it wouldn’t cover the eleventh link I add next month. Every login request already funnels through the /auth/* branch of src/proxy.ts, which is where the SDK middleware gets called. So that’s where I fill in the missing returnTo: when /auth/login arrives with none, I derive one from the request’s Referer — the page you were on when you clicked — and redirect once with it set.
The Referer is only trusted when it’s same-origin, so a spoofed header can’t turn login into an open redirect. Auth referers are dropped so login never loops back into itself, and the bare root is dropped because that’s the SDK default anyway. The logic lives in a pure loginReturnToFromReferer helper so it can be unit tested without dragging next/server and the whole Auth0 client into the test.
For the deny case I supplied my own onCallback on the Auth0Client. When Auth0 hands it an error, I check whether it’s the user declining consent — an AuthorizationError whose cause carries the access_denied code — and if so redirect back to ctx.returnTo (the page they started on, the same value the login fix captured) with ?authError=permissions appended.
I deliberately kept the change narrow. Only access_denied gets the friendly bounce; every other error still returns the original 500 so a real misconfig or network failure stays loud and debuggable instead of being papered over with a generic toast.
A small client component, AuthErrorToast, is mounted once in the root layout. It reads ?authError=permissions from the URL and shows a dismissible toast: You can’t log in without granting permissions. It’s a role="alert" with an assertive live region so a screen reader announces it, it auto-dismisses after a few seconds, and it respects prefers-reduced-motion.
Dismissing is local state, not a URL rewrite — the same call I made with the résumé’s interview notice. Rewriting the URL to strip the flag would break the back button, and leaving it there costs nothing but a lingering query param. I didn’t reuse the operator toast provider either; it’s mounted per-page, so wiring it in app-wide would double-render its notifications on the operator screens. A self-contained toast keeps the two apart.
One more thing surfaced after the toast shipped. Declining consent doesn’t end the Auth0 session — you authenticated fine, you just said no to the permissions. So the session cookie on the Auth0 domain is still live, and the next time you click log in, Auth0 sees it and jumps straight back to the consent screen. It never asks who’s logging in, which is exactly what you want to reconsider after a deny.
The SDK’s login handler forwards every query param it’s given straight onto the authorization request, so prompt=login forces Auth0 to re-authenticate. I didn’t want that on every login though — normal sign-ins should stay smooth. So on a denied consent the onCallback also sets a short-lived one-shot cookie, and the proxy adds prompt=login to the very next /auth/login and clears the cookie in the same response. One fresh prompt right after a deny, then straight back to normal. I reached for this instead of a full Auth0 logout because logout’s return URL has to be whitelisted in the tenant, and this needs no config at all.
The last piece was session length. I wanted a rolling six-hour idle window: being on the site and doing things keeps you signed in, but sit idle for six hours and you’re logged out and have to sign in again — and re-grant permissions. The SDK’s rolling sessions make the first half easy: with inactivityDuration at six hours and a longer absoluteDuration ceiling, every request pushes the expiry to six hours out, so activity resets the clock.
The hard part is noticing the timeout. Once the session cookie expires it’s just gone — there’s nothing left to tell a timed-out user apart from one who was never logged in. So I set a second, longer-lived marker cookie on every authenticated response. It outlives the session, and when the proxy sees a missing session but a lingering marker, that’s a timeout: it bounces the user to the landing page with a ?authError=timeout flag so the toast can render, clears the marker so it only says it once, and arms the next login with prompt=login so Auth0 asks who is signing in. Same one-shot cookie mechanism as the denied-consent case.
It armed prompt=consent at first, which did nothing. Per OIDC Core, consent re-asks for scope approval and explicitly does not re-authenticate, and for a first-party client Auth0 skips that screen anyway. So the timeout bounced you to the landing page, showed the toast, sent you to Auth0 — which still had its own tenant SSO cookie, on a lifetime set in the dashboard and entirely independent of this app’s six hours — and signed you straight back in as whoever you already were. The toast was telling the truth and the redirect was quietly undoing it.
Two sessions, not one, is the thing worth remembering. Expiring the local cookie tells Auth0 nothing. If you want a timeout to mean anything, the next login has to say so out loud.
Logging out told you your session had timed out. The marker cookie that makes the timeout toast possible outlives the session cookie on purpose — once the session is gone there is otherwise no way to tell someone who timed out from someone who was never signed in. But nothing cleared it on the way out, so choosing to leave left exactly the same evidence expiring does, and the next page load read it that way.
It is cleared on /auth/logout now, in the same /auth/* branch the other two fixes live in. Matched exactly rather than by prefix, so a route that merely starts with it is not swept up. Three bugs at this choke point now, which is either a good argument for centralising auth or a warning about how much one branch is carrying.