What this usually means
This class of bug almost always traces to a mismatch between the JWT callback's token return and the adapter's session creation. NextAuth v4 uses two separate flows: the JWT strategy (default for database-less setups) and the database strategy (when an adapter is present). If you have an adapter configured but the JWT callback expects a token with certain fields (like `user`), the session creation callback may receive `null` and silently drop the session. Another common cause is the session cookie (`next-auth.session-token`) being set with `SameSite=Lax` but lacking `Secure` flag when the site is served over HTTPS, causing the browser to strip the cookie on POST requests or cross-origin navigations. Additionally, if you're using a custom `jwt` callback that doesn't pass through the `sub` claim, the token becomes invalid on refresh, and NextAuth silently returns `null` for the session.
The first ten minutes — establish facts before touching code.
- 1Run `curl -v http://localhost:3000/api/auth/session` with your session cookie — check if the response includes `user` and `expires`. A `{}` response means the token is invalid.
- 2Open browser DevTools > Application > Cookies and check `next-auth.session-token` for `Secure`, `HttpOnly`, and `SameSite` flags. If `Secure` is missing over HTTPS, that's the problem.
- 3Add `logger: console` to your NextAuth config and watch server logs for `JWT_SESSION_ERROR` or `JWT_DECODE_ERROR` on page reload.
- 4Check your `[...nextauth].ts` file: if you have an `adapter` but the `jwt` callback returns a token without `user`, add `token.user = user` to the `session` callback or switch to database sessions.
- 5Deploy the app and check the `NEXTAUTH_URL` environment variable — if it's not set or mismatched (trailing slash, http vs https), the cookie domain won't match and the session will be lost.
The specific files, logs, configs, and dashboards that usually own this bug.
- search`pages/api/auth/[...nextauth].ts` — the JWT, session, and signIn callbacks.
- searchBrowser DevTools > Network tab > filter by `/api/auth/session` — inspect request headers for cookie, response headers for `Set-Cookie`.
- searchServer logs (stdout/stderr) — NextAuth debug logs when `logger: console` is set.
- search`lib/auth.ts` or wherever `getServerSession` is called — ensure it uses the same config object as the API route.
- searchEnvironment variables: `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `NEXTAUTH_URL_INTERNAL`.
- searchCookie settings in NextAuth config: `cookies.sessionToken.name`, `sameSite`, `secure`.
- searchMiddleware (if any) — check if `withAuth` or custom middleware is stripping cookies.
Practical causes, not theory. These are the things you will actually find.
- warningJWT callback does not return the token with the `sub` claim, causing `decode` to fail on refresh.
- warningSession callback expects `token.user` to exist but the JWT callback only passes `token.sub` and `token.email`.
- warning`NEXTAUTH_URL` is not set or does not match the actual deployment URL (e.g., missing `https://`, trailing slash).
- warningCookie `Secure` flag is missing when the app is served over HTTPS (common behind reverse proxy).
- warningUsing a database adapter but the `session` strategy is still `jwt` (default), causing session creation to be skipped.
- warningCustom `signIn` callback returns `false` on a condition that triggers on subsequent requests (e.g., IP check).
- warningThe `jwt` callback has a bug that throws an exception silently (NextAuth catches it and returns `null`).
Concrete fix directions. Pick the one that matches your root cause.
- buildEnsure `jwt` callback always returns a token with at least `sub` and `exp`. If using adapter, include `user` in the token.
- buildSet `session: { strategy: 'database' }` in NextAuth config if you have an adapter, and implement the `session` callback to populate from DB.
- buildExplicitly set `cookies.sessionToken.secure: process.env.NODE_ENV === 'production'` and `sameSite: 'lax'` (or `'none'` if cross-site).
- buildSet `NEXTAUTH_URL_INTERNAL` to the internal URL (e.g., `http://localhost:3000`) when behind a reverse proxy to avoid cookie domain mismatch.
- buildAdd error handling in `jwt` callback: wrap token logic in try/catch and log the error. Return the original token on failure.
- buildIf using custom `signIn` callback, ensure it returns `true` for already authenticated users (check `account` provider).
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter fix, open a new incognito window, log in, then close and reopen the browser. The session should persist.
- verifiedRun `curl -v -b cookies.txt https://yourdomain.com/api/auth/session` and confirm the response includes `user` and `expires`.
- verifiedCheck the server logs for any `JWT_SESSION_ERROR` or `JWT_DECODE_ERROR` after a page reload.
- verifiedUse `useSession` on the client and call `getSession()` after a 5-minute delay — the token should refresh silently.
- verifiedDeploy to a staging environment that mirrors production (HTTPS, reverse proxy) and test navigation flow.
- verifiedAdd a health check endpoint that calls `getServerSession` and returns the session state — monitor it after deployment.
Things that make this bug worse or harder to find.
- warningDo not set `NEXTAUTH_URL` with a trailing slash — it breaks cookie path matching.
- warningDo not mix JWT and database strategies without verifying both callbacks pass the expected fields.
- warningDo not rely on client-side session alone for authorization — always use `getServerSession` for API routes.
- warningDo not forget to restart the server after changing environment variables or NextAuth config.
- warningDo not use `SameSite=none` without `Secure=true` — browsers will reject the cookie.
- warningDo not assume the session is lost due to a bug in NextAuth — check the token manually with `jose.decode`.
The Phantom Logout: How a Missing `sub` Claim Cost Us a Morning
Timeline
- 09:15User reports that after logging in, navigating to any page refreshes and shows 'Unauthorized'. Works on localhost.
- 09:30I check `getServerSession` on a test route — it returns `null` on the server, but client `useSession` shows user data for 10 seconds.
- 09:45Add `logger: console` to NextAuth config. See no errors in server logs on page reload.
- 10:00Inspect production cookies: `next-auth.session-token` is present, `Secure` flag is set, `SameSite=Lax`. Cookie value looks like a JWT.
- 10:15Decode JWT manually using `jose.decode` — the token contains `sub`, `email`, `iat`, `exp`. No `user` object.
- 10:30Realize our `jwt` callback returns a token with `user` only if `account` is present (first login). On subsequent calls, `token.user` is `undefined`. But the `session` callback expects `token.user`.
- 10:45Fix: in `jwt` callback, always set `token.user` from database if not present. Also added `token.sub` explicitly.
- 11:00Deploy fix. Session persists across page reloads. Root cause: missing `user` in token caused `session` callback to return `null`, which NextAuth interpreted as 'no session'.
I was woken up by a Slack ping at 9 AM — users couldn't stay logged in. The app worked fine on my machine, but in production, every page navigation logged you out. I opened DevTools and saw the session cookie was present, so NextAuth was sending it. But the `/api/auth/session` endpoint was returning `{}`. That meant the server couldn't decode the token.
`getServerSession` was returning `null` on the server side, but the client-side `useSession` hook still showed data for a few seconds — that's because the client caches the session in memory. Once it tried to refresh via the API, it got `null` and the UI blanked. The logs were clean because NextAuth silently returns `null` on decode failure.
I manually decoded the JWT from the cookie using `jose.decode` and saw it had `sub` and `email`, but no `user` object. Our `jwt` callback only added `user` on the first sign-in (when `account` is present). On subsequent requests, `token.user` was `undefined`. The `session` callback then did `session.user = token.user`, which became `null`. That was the bug: the session callback was never meant to receive a null user. One line fix: always fetch the user from DB and attach it to the token. Deployed, session persisted. The lesson: never assume the `jwt` callback will be called with `account` every time.
Root cause
The `jwt` callback conditionally set `token.user` only when `account` was present (first login). On subsequent token refreshes, `token.user` was undefined, causing the `session` callback to return `null`.
The fix
In the `jwt` callback, always fetch the user from the database using `token.sub` and set `token.user`. If the database call fails, return the token as-is to avoid breaking the session completely.
The lesson
NextAuth's JWT callback is called on every request, not just on sign-in. Any conditional logic that depends on `account` must also handle the case where `account` is undefined. Always ensure the token contains all fields the `session` callback expects.
NextAuth has two session strategies: `jwt` (default) and `database`. With `jwt`, the entire session is encoded in a JWT stored in a cookie. On every request, the `jwt` callback is invoked to decode and optionally modify the token. The `session` callback then maps the token to a session object returned by the API. If the `session` callback returns `null`, NextAuth treats it as 'no session' and clears the cookie.
The flow is: 1) User signs in → `signIn` callback → `jwt` callback (with `account` and `user`) → token is set in cookie. 2) Every subsequent request: cookie is sent → `jwt` callback (without `account`, only `token`) �� `session` callback. If the `jwt` callback throws or returns `null`, the token is invalidated and the session is lost silently.
The JWT has an expiry (`exp`) set by NextAuth (default 30 days). But on every request, NextAuth may refresh the token by calling the `jwt` callback again. If the callback modifies the token in a way that removes a required claim (like `sub`), the new token becomes invalid. The old token is overwritten, and the session dies without any visible error.
To detect this, add a `try/catch` in the `jwt` callback and log the error. Also, check the `jwt` callback's second argument: `account` is only present on sign-in, `user` is only present on sign-in with credentials provider. Never assume these exist.
If you configure a database adapter (e.g., Prisma) but leave the session strategy as `jwt`, NextAuth will create a session in the database on sign-in (because the adapter is present) but still use JWT for session tokens. The `session` callback receives the token, not the database session. If you then query the database in the `session` callback expecting a session row, you might get `null` if the row was deleted or never created.
Solution: either switch `session: { strategy: 'database' }` and use the `session` callback to return the full user from DB, or remove the adapter entirely if you don't need database sessions.
Frequently asked questions
Why does my session work on localhost but not in production?
Most common cause: `NEXTAUTH_URL` missing or mismatched. In production, set it to the public URL (e.g., `https://example.com`). Also check cookie `Secure` flag: if production uses HTTPS but the flag is not set, the browser will reject the cookie. Use `process.env.NEXTAUTH_URL` or set `secure: process.env.NODE_ENV === 'production'` in the cookie options.
My session persists for exactly 30 days then disappears — is that a bug?
That's the default JWT expiry. NextAuth v4 sets `maxAge: 30 * 24 * 60 * 60` (30 days) for the session token. If you want rolling sessions, you must implement a refresh in the `jwt` callback by resetting `token.exp`. Otherwise, the token expires and the user is logged out. To extend, set `session.maxAge` in the NextAuth config.
Why does `getServerSession` return null but `useSession` has data?
The client-side `useSession` caches the session in memory from the initial page load. When you navigate, the client fetches `/api/auth/session` again. If that returns null, the client updates to null. The server-side session is always fresh from the cookie. If you see this, the token is invalid and the server can't decode it. Check the JWT callback and token encoding.
Can multiple NextAuth instances cause session conflicts?
Yes. If you have multiple API routes with different NextAuth configs (e.g., one for admin, one for users), they will use the same cookie name by default. The first config might set a token, but the second config might not decode it correctly because it uses a different secret or callbacks. Ensure you have a single NextAuth instance and share it across the app using a module export.
How do I debug a session that drops after a few minutes?
This is often a token refresh issue. Enable `logger: console` in NextAuth and watch for `JWT_SESSION_ERROR`. Also, check the `jwt` callback: if you are modifying the token (e.g., adding roles), ensure you are not accidentally removing the `sub` claim. Use `jose.decode` on the cookie value to inspect the token before and after refresh.