supabase: Refresh token errors in @supabase/ssr

Bug report

  • I confirm this is a bug with Supabase, not with my own application.
  • I confirm I have searched the Docs, GitHub Discussions, and Discord.

Describe the bug

If you use the serverClient on multiple layouts in the nextjs app, this will trigger a race condition on who gets to refresh the token first. The request that refreshes the token first will get the correct token, but if another refresh request is initiated from another route, this will cause the app to error with AuthApiError: Invalid Refresh Token: Already Used

To Reproduce

This happens randomly, I have the new @supbase/ssr in the next 14 app router, use it in the middleware for auth and also on various layout components to display user specific content

Expected behavior

App doesn’t crash 😃

Screenshots

If applicable, add screenshots to help explain your problem.

System information

  • OS: macOS
  • Browser (if applies) [e.g. chrome, safari]
  • Version of @supabase/ssr: 0.0.10
  • Version of Node.js: v18.17.1
  • Next 14.0.1

Additional context

Add any other context about the problem here.

About this issue

  • Original URL
  • State: closed
  • Created 8 months ago
  • Comments: 22 (3 by maintainers)

Most upvoted comments

You can take a look at our most recently updated Next.js Auth guide that describes how to set up middleware: https://supabase.com/docs/guides/auth/server-side/nextjs

The key points are:

  1. The middleware is responsible for refreshing the token (by calling supabase.auth.getUser)
  2. This refreshed token needs to be passed to Server Components, so they don’t attempt to refresh the token themselves (since refreshing twice will terminate your session as a security measure) – this is accomplished with request.cookies.set
  3. The refreshed token also needs to be passed to the browser, so that it replaces the old token (otherwise the browser will have a stale token, and the next call to the server will trigger the refreshing twice problem) – this is accomplished with response.cookies.set

Hey @eposha, the code you posted still doesn’t work for me. Even after setting those cookies on the next reload, it still seems to read the session as null.

Also, the code is not handling the case where the cookie size is split in multiple chunks, which might cause weird errors down the line.

The set cookie in the middleware never gets called. And neither is remove. If you enable debug: true in the middelware client you can see a lot of crazy stuff happening so I’m actually not sure who actually sets that original login cookie in your browser https://github.com/vercel/next.js/blob/778fb871314e840390496f4147483ba18d974d83/examples/with-supabase/utils/supabase/middleware.ts#L20

This is rather frustrating and I’m really surprised that they provided migration docs for a non-working library

Okay awesome, glad it’s fixed 👍

I need to revisit creating a supabase client in middleware docs! Thanks for letting us know!

Quick one to call out from your above example, autoRefreshToken should not be set to true if creating a client server-side. This sets up a timer to refresh the session, which should only be run in the browser 👍

So this code in middleware resolve problem for me

You should set session in the cookies every time when middleware was called

response.cookies.set({
      name: 'sb-{YOUR_ID}-auth-token',
      value: JSON.stringify(session),
      path: '/',
    });

I am using NextJS: 14.0.3 and @supabase/ssr: 0.0.10. Recently I noticed several AuthApiError: Rate limit exceeded errors. Adding the response.cookies.set snippet as suggested above in my middleware resolved the issue.

@cipriancaba I have a question for you, can you share with me your solution with redirection? I have a similar issue as you reported. And more a less same desire behaviour. In middleware, if the user doesn’t have a session I redirect him to login. But everytime after login in supabase first redirects to (‘/’) it has session null. Whenever I manually refresh the page it redirects user correctly to the ‘/’ with session. Thanks for a help!

That’s an interesting idea @dijonmusters which would probably make sense for a public app, but I only have a dashboard where ALL the routes should be protected. I want to deny access in the middleware if the user is not logged in and for that I definitely need to check the user has a valid session, otherwise redirect to the /login route.

From a workflow perspective, i would want the following to happen:

  • access any route other than /login => triggers middleware (which i already use for i18n) which refreshes the session and sets a response cookie with the refreshed tokens
  • on any server component / other route, the session should now always be refreshed and valid

This seems it should work out of the box but for some reason the invalid refresh token randomly gets triggered and I am not sure where. I’ve disabled autorefreshtoken in every server component so only middleware would be responsible for refreshing the token, but that also doesn’t seem to do the trick

All that being said, I did make a change yesterday which seems to have fixed the issue up until this point. In the migration docs, there is this snippet for the middleware code:

https://supabase.com/docs/guides/auth/server-side/creating-a-client?environment=middleware

const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return request.cookies.get(name)?.value
        },
        set(name: string, value: string, options: CookieOptions) {
          request.cookies.set({
            name,
            value,
            ...options,
          })
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          })
        },
        remove(name: string, options: CookieOptions) {
          request.cookies.set({
            name,
            value: '',
            ...options,
          })
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          })
        },
      },
    }
  )

I’ve updated that to the following:

const supabase = createServerClient<Database>(
    env.NEXT_PUBLIC_SUPABASE_URL,
    env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
    {
      auth: {
        autoRefreshToken: true,
      },
      cookies: {
        get(name: string) {
          console.log('Get cookies => ', name)
          return request.cookies.get(name)?.value
        },
        set(name: string, value: string, options: CookieOptions) {
          console.log('==================> Set cookies ', name, value, options)
          response.cookies.set({
            name,
            value,
            ...options,
          })
        },
        remove(name: string, options: CookieOptions) {
          console.log('Remove cookies ---------------------> ', name, options)
          
          response.cookies.set({
            name,
            value: '',
            ...options,
          })
        },
      },
    }
  )

Basically removed the request.setCookie which seems that it was failing and then the response.setCookie never got called

I am getting: AuthApiError: missing destination name refreshed_at in *models.Session causing a re-login.

@cipriancaba I found the source of the problem

The thing is that when getSession updates the session because JWT expired, the cookies remain the same and are not overwritten with the new data from the updated session.

const {
    data: { session },
  } = await supabase.auth.getSession();
  
  console.log('session', session?.refresh_token);

After session revalidation we get a new refresh_token but the cookies remain the same

@silentworks FYI