Middleware crashes when JWT is expired
Checklist
- [x] The issue can be reproduced in the nextjs-auth0 sample app (or N/A).
- [x] I have looked into the Readme, Examples, and FAQ and have not found a suitable solution or answer.
- [x] I have looked into the API documentation and have not found a suitable solution or answer.
- [x] I have searched the issues and have not found a suitable solution or answer.
- [x] I have searched the Auth0 Community forums and have not found a suitable solution or answer.
- [x] I agree to the terms within the Auth0 Code of Conduct.
Description
In v4.5.1 (latest version at the time of writing), a security patch was released to ensure JWTs are expired alongside the cookies. However, that has caused some side-effects - the middleware now crashes with HTTP 500 whenever an expired JWT hits the server:
[JWTExpired: "exp" claim timestamp check failed]
I am not sure this is an intentional behaviour or an oversight? Coming from the user's perspective, I felt like the middleware should have caught this and handled as if there is no session, instead of throwing. How it is handled could dependant on the route - e.g. profile should return 401, and for non-auth routes probably short circuit the session updates.
Reproduction
- Obtain an expired JWT
- Hit the Next.js server wired up with the Auth0 middleware
- Boom, HTTP 500
Additional context
No response
nextjs-auth0 version
4.5.1
Next.js version
14.2.28
Node.js version
22.14.0
Thanks, this is definetly not intentional. I am working on a fix here.
Any chance you can elaborate what you exacly mean when you say to obtain an expired JWT? And how do you use it? Even though I think we definitely should handle this better, I am also trying to understand and reproduce your exact use-case to ensure it's also solved in the changes being introduced.
Any chance you can elaborate what you exacly mean when you say to obtain an expired JWT? And how do you use it?
We have a fairly standard set up actually - with the middleware and extra auth checks on every page. Obtaining an expired JWT is just a way to reproduce the problem.
The only special thing (that makes the issue very pronounced for us) is@ that we use short absolute/idle session durations (both 1h).
I tried a few scenario's, but was not able to reproduce. I still think we can handle the expired JWE better, but I am not able to reproduce your exact scenario in order to retrieve the error you are getting.
What I tried was:
1. Absolute Duration, and use an Access Token that expires after the cookie does.
export const auth0 = new Auth0Client({
session: {
rolling: false,
absoluteDuration: 2 * 60, // 2 minutes in seconds
}
})
When I start the app and login, I wait until the cookie expires and navigate again. I see the middleware being triggered, but it can not find a session because the cookie was not send along the request, and the user appears logged out, as expected.
2. Absolute Duration, and use an Access Token that expires before the cookie does.
Then, I did the same but ensured the JWT inside of the cookie expires before the Cookie does. When I navigate at the moment the Access Token is expired, but the cookie isnt, I can see the access token to be refreshed, and the cookie to be updated with that access token.
3. Absolute Duration, and use an Access Token that expires before the cookie does while not using refresh tokens. That made me try without refresh tokens, but still use an access token that expires before the cookie does, which, once the access token is expired, but the cookie isnt, results in:
AccessTokenError: The access token has expired and a refresh token was not provided. The user needs to re-authenticate.
I am only getting this error because I am explicitly using auth0.getAccessToken() in the middleware, if I don't I get no error at all.
Any chance you can try and reproduce the behavior in a small application to help us troubleshoot?
Hi! We are experiencing the same issue. It has become a dependency for successfully updating to Next.js 15
For me this problem appears has when:
- make access token short 1-2 min
- refresh token - 1 day
- open the app (with server-action button)
- wait 2-3 min
- click to this button (without refresh the page)
I use from docs
auth0.middleware(request);
Next.js 15.3.2
For now I wrote this workaroud but this looks dirty
let resp: NextResponse;
try {
resp = await auth0.middleware(request);
} catch (error) {
console.warn('[auth0] Token expired — redirecting to /login', error);
const loginUrl = new URL('/auth/login', request.url);
loginUrl.searchParams.set('returnTo', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
... // some logic for nonce and another stuff
return resp;
I tried the following using [email protected] and @auth0/[email protected] :
- I configured the API in Auth0 to issue tokens that expire after 120 seconds.
- I added the following Server Action:
async function trigger() {
'use server'
// Mutate data
console.log('Called server action');
}
- And called it like this:
<form action={trigger}><button>Trigger</button></form>
- My middleware looks like this:
import type { NextRequest } from "next/server"
import { auth0 } from "./lib/auth0"
export async function middleware(request: NextRequest) {
return await auth0.middleware(request);
}
Then I open the app and I waited 5 minute. Once I click the button, I am not getting an error.
I created a branch that contains my changes to our sample app, any chance someone can reproduce it in that application? I think this would help speed up reproduction, and confirm resolution.
To pull it locally, you can do:
git clone https://github.com/auth0/nextjs-auth0git checkout repro/2081cd nextjs-auth0npm i -g pnpmcd examples/with-shadcn
Then, rename .env.template to .env and fill in information and update lib/auth0 to include the authorizationParameters.audience and run:
pnpm ipnpm run dev
Then, point your browser to http://localhost:3000 and try and reproduce.
We just updated from 4.5.1 to 4.6.0 and encountered this error with an old/existing token locally - meaning the token was generated with 4.5.1 and had expired.
Edit: Failed even after downgrading. Haven't seen the error before though, strange. Removing the token and issuing a new one obviously works.
Can you elaborate what it means when you say token, and remove token (which token?)? Based on what I can tell, the expiration here is for the JWE we use to encrypt the Cookie (which contains the Access Token, as well as Id Token and Refresh Token), not the Access Token.
I haven't been able to reproduce this, and I understand this is a frustrating issue. It's difficult to actually fix this without being able to reproduce, so it would realy help if someone can look at https://github.com/auth0/nextjs-auth0/issues/2081#issuecomment-2879068330 and provide the requested reproduction steps.
hello there! We've been getting this error on and off in our dev environments, thankfully we haven't seen this in production yet.
It happened to me just now when I started my dev server again. It happens randomly, so I don't have repro steps unfortunately. The only way around it is to nuke the cookies.
it happens in the middleware() call
// middleware.ts
export async function middleware(request: NextRequest) {
authRes = await auth0.middleware(request); // <- blows up here
I caught the error and printed the stacktrace, does it help?
JWTExpired: "exp" claim timestamp check failed
at __WEBPACK_DEFAULT_EXPORT__ (webpack-internal:///(middleware)/./node_modules/.pnpm/[email protected]/node_modules/jose/dist/browser/lib/jwt_claims_set.js:99:19)
at Module.jwtDecrypt (webpack-internal:///(middleware)/./node_modules/.pnpm/[email protected]/node_modules/jose/dist/browser/jwt/decrypt.js:13:87)
at async Module.decrypt (webpack-internal:///(middleware)/./node_modules/.pnpm/@[email protected][email protected]_@[email protected]_@[email protected]_@playwright_lnzz657jnbyvdhxozrbulhgehe/node_modules/@auth0/nextjs-auth0/dist/server/cookies.js:38:20)
at async StatelessSessionStore.get (webpack-internal:///(middleware)/./node_modules/.pnpm/@[email protected][email protected]_@[email protected]_@[email protected]_@playwright_lnzz657jnbyvdhxozrbulhgehe/node_modules/@auth0/nextjs-auth0/dist/server/session/stateless-session-store.js:28:33)
at async AuthClient.handleLogout (webpack-internal:///(middleware)/./node_modules/.pnpm/@[email protected][email protected]_@[email protected]_@[email protected]_@playwright_lnzz657jnbyvdhxozrbulhgehe/node_modules/@auth0/nextjs-auth0/dist/server/auth-client.js:239:25)
at async middleware$1 (webpack-internal:///(middleware)/./middleware.ts:43:19)
at async eval (webpack-internal:///(middleware)/./node_modules/.pnpm/[email protected]_@[email protected]_@[email protected]_@[email protected]_react-dom@19._gcyhqsl2j2bt2ftfeh5rvwhlxq/node_modules/next/dist/build/webpack/loaders/next-middleware-loader.js?absolutePagePath=%2FUsers%2FXXXX%2Fmyapp%2Fweb%2Fmiddleware.ts&page=%2Fmiddleware&rootDir=%2FUsers%2Fkostis%2FWorkspace%2Fwordsmith%2Fwordsmith%2Fweb&matchers=&preferredRegion=&middlewareConfig=e30%3D!:32:20)
at async eval (webpack-internal:///(middleware)/./node_modules/.pnpm/[email protected]_@[email protected]_@[email protected]_@[email protected]_react-dom@19._gcyhqsl2j2bt2ftfeh5rvwhlxq/node_modules/next/dist/esm/server/web/adapter.js:247:28)
at async adapter (webpack-internal:///(middleware)/./node_modules/.pnpm/[email protected]_@[email protected]_@[email protected]_@[email protected]_react-dom@19._gcyhqsl2j2bt2ftfeh5rvwhlxq/node_modules/next/dist/esm/server/web/adapter.js:203:16)
at async /Users/XXXX/myapp/web/node_modules/.pnpm/[email protected]_@[email protected]_@[email protected]_@[email protected]_react-dom@19._gcyhqsl2j2bt2ftfeh5rvwhlxq/node_modules/next/dist/server/web/sandbox/sandbox.js:108:26
Fyi, we're using "@auth0/nextjs-auth0": "4.5.1"
Can you see how this error could be produced in the middleware() call?
Also, when I console.log(error):
JWTExpired: "exp" claim timestamp check failed
at async middleware$1 (webpack-internal:/(middleware)/middleware.ts:33:14)
31 | let authRes;
32 | try {
> 33 | authRes = await auth0.middleware(request);
| ^
34 | } catch (error) {
36 | console.log(error); {
code: 'ERR_JWT_EXPIRED',
claim: 'exp',
reason: 'check_failed',
payload: {
user: {
given_name: 'XXXX',
family_name: 'XXXX',
nickname: 'XXXX',
name: 'XXXX',
picture: 'https://lh3.googleusercontent.com/a/XXXX',
email: 'XXXX,
email_verified: true,
sub: 'google-oauth2|XXXXX'
},
tokenSet: {
accessToken: 'XXXX',
idToken: 'XXXX',
scope: 'openid profile email offline_access',
refreshToken: 'XXXX',
expiresAt: 1748633901
},
internal: { sid: 'XXXXX', createdAt: 1748478603 },
exp: 1748720181
}
the exp and expiresAt timestamps are in the past
From my testing after extensive work tracking the cause of this bug I have been able to resolve it in our production/staging/development environments. At first I thought it specifically an issue with the library but after reviewing the various layers in our middleware I found that it was coming from bad returns from the middleware process, possibly combined with the issue outlined in #2124, which caused some pages to trigger the authentication middleware rapidly from server actions.
To elaborate on the underlying issue our application is localized with i18n-next and our middleware that handles detecting and setting cookies for this comes after authentication is called but before authentication response is returned:
// Scratch code, not real code!!!
const authentication = await auth0.middleware(request);
if (path.startsWith('/auth')) return authentication;
const locale = handleLocaleRedirect(request, authentication);
if (locale) return locale;
A developer mistakenly thought that handleLocaleRedirect would merge together authentication headers but this was missing. Instead the response from authentication was dropped and the user received either a new NextResponse or a redirect NextResponse. This means that on requests where an i18n redirect did not occur the user would have the authentication middleware triggered (side-effects like updating cookies) but the user would not receive the updated cookies.
By revising our handleLocaleRedirect function we were able to merge headers as expected, there is a starting point example for this here in the repository. With this change in place the error was eliminated for us, hopefully this information can help others here. I highly recommend reviewing your other handlers in your middleware to check if one of them might be losing parts of the authentication headers.
Similar problem on #1934. Still waiting for fix.
We've encountered the exact same issue with our website, and this is in production. Hopefully the fix can be released soon.
As mentioned above we have been unable to reproduce this and have asked for a reproduction to help this move forward.
Even more so, above it was called out by someone to be an issue on their end. I am not saying this can not be an issue in the SDK, but I have not been able to reproduce it.
@frederikprijck I've followed your guide and spin up an instance locally and can reproduce the issue.
The problem is that whenever the return await auth0.middleware(request); has encountered an error, the error directly crashes the middleware and causes 500 error to return. It needs to have an error boundary.
To simulate the error in middleware, I first login normally with my account. Instead of changing timeout and things, I simply go to the browser console, and find the cookie named __session__1 , and change the last character in its content to something else, so it can't successfully decrypt the content. Though it is a different scenario, it simulates the same JWT decryption error others and I have been encountering for various reasons.
After changing the cookie value, I simply reload the web page, and now it throws the error like below:
I think the fix would be to catch the errors in middleware and treat the cookie as not valid or not present, and clear the user session. So my expectation is that the page either displays to a guest user, or if the page is protected, navigate to the login page.
Even though I understand that, and I see how and why it crashes, that is not a valid scenario and I would like to ensure I can reproduce a valid scenario here as well (it's a different error when the JWE is expired vs when the JWE is invalid).
I have a PR ready that I think could solve this and aligns with what you are saying: https://github.com/auth0/nextjs-auth0/pull/2082 But this PR is built purely theoretical, and I am unable to verify the changes as I can not reproduce the behavior so I want to be able to verify this before releasing.
I had the same issue this morning
Even though I understand that, and I see how and why it crashes, that is not a valid scenario and I would like to ensure I can reproduce a valid scenario here as well (it's a different error when the JWE is expired vs when the JWE is invalid).
I have a PR ready that I think could solve this and aligns with what you are saying: #2082 But this PR is built purely theoretical, and I am unable to verify the changes as I can not reproduce the behavior so I want to be able to verify this before releasing.
Faire enough, I've reproduced the exact same error again.
Make the below changes:
export const auth0 = new Auth0Client({
authorizationParameters: {
audience: 'The audience',
},
// Short session settings to trigger JWTExpired ("exp" claim) reliably.
session: {
rolling: true,
absoluteDuration: 3,
inactivityDuration: 1,
// Transient session cookie so JWE expires before browser drops it
cookie: { transient: true },
}
})
Then load the page, login, and wait for 5 seconds, and then refresh the whole page.
I guess the root issue is that the browser sometimes still sends the cookie to the server after expiry or that expiry time was somehow overwritten. Anyway, the server fails to decode the token, and exception was thrown.
Thanks, that allows me to reproduce. I think the key here is indeed transient.
https://github.com/auth0/nextjs-auth0/pull/2082 does solve it, but we need to consider if we also need to be dropping the cookie.
Is there any update to fix this issue.
I'm using :
- "@auth0/nextjs-auth0": "^4.7.0"
- "auth0": "^4.26.0"
My middleware
import { NextRequest, NextResponse } from "next/server"
import { auth0 } from "@/app/api/lib/auth0"
import { apiEndpoints } from "./lib/conf";
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth") || request.nextUrl.pathname === "/" ) {
return authRes;
}
else {
const session = await auth0.getSession(request);
if (!session ) {
if(request.nextUrl.pathname === apiEndpoints.logout_url || request.nextUrl.pathname === apiEndpoints.login_url) {
return NextResponse.redirect(new URL("/", request.url));
}
if (request.nextUrl.pathname.startsWith("/api")) {
return NextResponse.json({ error: "Unauthorized, login to have access" }, { status: 401 });
}
return NextResponse.redirect(new URL("/", request.url));
}
}
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
* - logo.png (logo file)
*/
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|logo.png).*)",
],
};
And my auth0 config is here :
import { env } from "@/env/server";
import { AuthorizationError } from "@auth0/nextjs-auth0/errors";
import { Auth0Client } from "@auth0/nextjs-auth0/server";
import { NextResponse } from "next/server";
// Initialize the Auth0 client
export const auth0 = new Auth0Client({
/** Important to get roles and permission see https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#beforesessionsaved*/
async beforeSessionSaved(session){
return {
...session,
user : {
...session.user,
// roles: session.user['prime.roles'],
// permissions: session.user['prime.permissions'],
}
}
},
authorizationParameters: {
// In v4, the AUTH0_SCOPE and AUTH0_AUDIENCE environment variables for API authorized applications are no longer automatically picked up by the SDK.
// Instead, we need to provide the values explicitly.
scope: env.AUTH0_SCOPE,
audience: env.AUTH0_AUDIENCE,
},
async onCallback(error, context) {
const authError = error as AuthorizationError
if (authError) {
console.log("Error in onCallback", JSON.stringify(authError));
const errorCode = "Access Denied"
const errorDescription = authError.cause?.message
const errorParams = new URLSearchParams({
error: encodeURIComponent(errorCode),
error_description: encodeURIComponent(errorDescription)
});
return NextResponse.redirect(
new URL(`/auth/error?${errorParams.toString()}`, env.APP_BASE_URL)
);
}
return NextResponse.redirect(
new URL(context.returnTo || "/", env.APP_BASE_URL)
);
},
});
After that I updated the packages to the latest, now I have this error
Error: `cookies` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context
at throwForMissingRequestStore (file://D:\Projets\prime\.next\server\edge\chunks\_64501f48._.js:11276:33)
at getExpectedRequestStore (file://D:\Projets\prime\.next\server\edge\chunks\_64501f48._.js:11244:9)
at cookies (file://D:\Projets\prime\.next\server\edge\chunks\_64501f48._.js:12633:84)
at Auth0Client.getSession (file://D:\Projets\prime\.next\server\edge\chunks\node_modules_@auth0_nextjs-auth0_7d4b69b9._.js:1673:195)
at middleware (file://D:\Projets\prime\.next\server\edge\chunks\[root-of-the-server]__12e9c95b._.js:440:178)
at async (file://D:\Projets\prime\.next\server\edge\chunks\_64501f48._.js:13929:20)
at async (file://D:\Projets\prime\.next\server\edge\chunks\_64501f48._.js:6842:28)
at async adapter (file://D:\Projets\prime\.next\server\edge\chunks\_64501f48._.js:6793:16)
at async (file://D:\Projets\prime\node_modules\next\dist\server\web\sandbox\sandbox.js:108:26)
at async runWithTaggedErrors (file://D:\Projets\prime\node_modules\next\dist\server\web\sandbox\sandbox.js:105:9)
caused by this route /api/v1/users/logins
import { logService } from "@/app/api";
import { env } from "@/env/server";
import { NextResponse , after} from "next/server";
export async function GET() {
after(async () => {
logService.createLog("login", "user logged in");
})
return NextResponse.redirect(new URL("/dashboard", env.APP_BASE_URL));
}
I solved the last error by following this https://community.auth0.com/t/error-error-cookies-was-called-outside-a-request-scope/188318
is there any updates for this?
I think the last update fixed the problem, cause now it redirect to the login page when session is invalid or expired.
Important thing is to add request to getSession method like this :
const session = await auth0.getSession(request);
But to be sure that the problem is fixed completly, we need the @frederikprijck confirmation
Are you sure @Ahmat-2000 ? As much as I would love for it to be resolved, the PR where I am fixing it has not been merged and is awaiting approval + merge. So either this was fixed as a side-effect of another change, or we are not talking about the same problem.
@frederikprijck I got this right now on prod for old users with expired claim
@frederikprijck I confirm that it work correctly with the new version session but if a user had an expired session from older version, it throw the exception Clam timestamp check failed. So right now I just ask user with old session to clear cache or hard refresh and it work
Any updates on this? We're currently asking users to clear their cookies as a workaround until this PR is merged. Appreciate the quick turnaround.
@carnivale7898 did you update auth0 to latest ?
FYI, #2082 has been merged and we'll make a release soon.