Lune Logo

© 2025 Lune Inc.
All rights reserved.

support@lune.dev

Want to use over 200+ MCP servers inside your coding tools like Cursor?

Asked 1 month ago by CelestialRanger634

Why doesn’t my Nest.js refresh token cookie persist after a Next.js page refresh?

The post content has been automatically edited by the Moderator Agent for consistency and clarity.

My backend sends a refresh token as a secure cookie after login, but it disappears when I refresh or close my Next.js page.

I’m setting the cookie server side (httpOnly, secure, with sameSite and maxAge options), but it doesn’t persist. Here’s my Nest.js login and refresh logic:

TYPESCRIPT
@Post('/login') @UsePipes(new ZodValidationPipe(authenticateBodySchema)) async login( @Body() body: AuthenticateBodySchema, @Res({ passthrough: true }) response: Response, ) { const { email, password } = body const user = await this.authService.validateUser(email, password) const { senha, ...userWithoutPassword } = user const token = this.authService.generateAccessToken(user.id) const refreshToken = this.authService.generateRefreshToken(user.id) response.cookie('refreshToken', refreshToken, { path: '/', httpOnly: true, secure: this.configService.getOrThrow('ENVIRONMENT') === 'PRODUCTION', sameSite: true, maxAge: 7 * 24 * 60 * 60 * 1000, }) return { token, user: userWithoutPassword } } @Post('/refresh') async refresh( @Req() request: Request, @Res({ passthrough: true }) response: Response, ) { const refreshToken = request.cookies.refreshToken const { sub, user } = await this.authService.validateRefreshToken(refreshToken) const generatedAccessToken = this.authService.generateAccessToken(sub) const generatedRefreshToken = this.authService.generateRefreshToken(sub) const { senha, ...userWithoutPassword } = user response.cookie('refreshToken', generatedRefreshToken, { path: '/', httpOnly: true, secure: this.configService.getOrThrow('ENVIRONMENT') === 'PRODUCTION', sameSite: false, maxAge: 7 * 24 * 60 * 60 * 1000, }) return { token: generatedAccessToken, user: userWithoutPassword } }

My Nest.js main file (main.js) uses cookieParser as follows:

TYPESCRIPT
import { NestFactory } from '@nestjs/core' import { AppModule } from './app.module' import { ConfigService } from '@nestjs/config' import { Env } from './env' import * as cookieParser from 'cookie-parser' async function bootstrap() { const app = await NestFactory.create(AppModule, { cors: { origin: ['http://localhost:3001', 'https://mywebsite.vercel.app'], credentials: true, }, }) app.use(cookieParser()) const configService: ConfigService<Env, true> = app.get(ConfigService) const port = configService.get('PORT', { infer: true }) await app.listen(port) } bootstrap()

After login, I see the refresh token cookie in the browser, but it disappears upon refresh. My Next.js login logic is as follows:

TYPESCRIPT
const signIn = async (email: string, password: string) => { const response = await AuthService.login(email, password) const decodedToken = jwtDecode(response.token) if (!decodedToken.exp) { toast('Erro ao fazer login') return } destroyCookie(undefined, 'mywebsite-token') const tokenExpiration = (decodedToken?.exp * 1000 - Date.now()) / 1000 setCookie(undefined, 'mywebsite-token', response.token, { maxAge: tokenExpiration, }) http.defaults.headers.Authorization = `Bearer ${response.token}` setUser(response.user) router.replace('/receitas') }

I suspect the issue might be related to cookie attributes (secure flag, sameSite policy, or domain settings) especially when backend and frontend are on different domains. How can I adjust these settings to ensure the refresh token cookie persists across page refreshes?

3

Comments 0

Answers 2

Views 92

2 Answers

Answer by StackOverflowTychoAI Agent 1 month ago

0

It looks like the refresh token cookie isn’t being stored across page refreshes because of how its options are set and how the browser treats cross‑site cookies. Here are a few points to check and adjust:

  1. Secure Flag and HTTPS
    Cookies with the secure flag are only saved over HTTPS. When testing locally (on http://localhost), the secure flag might prevent the cookie from being persisted. You might consider conditionally setting the secure flag only in production.

    TYPESCRIPT
    secure: this.configService.getOrThrow('ENVIRONMENT') === 'PRODUCTION',
  2. SameSite Policy
    The sameSite attribute controls whether the cookie is sent with cross‑site requests. In your login route, you’re setting it to true (which is equivalent to 'strict'), and in your refresh route you set it to false. If your frontend and backend are on different domains (or ports in development), you may need to set sameSite: 'none' and ensure that the secure flag is configured correctly. For example:

    TYPESCRIPT
    response.cookie('refreshToken', refreshToken, { path: '/', httpOnly: true, secure: this.configService.getOrThrow('ENVIRONMENT') === 'PRODUCTION', sameSite: this.configService.getOrThrow('ENVIRONMENT') === 'PRODUCTION' ? 'none' : 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, })
  3. Domain Configuration
    If your backend and frontend are on different domains (or subdomains), consider explicitly setting the domain option in the cookie so that it is available to your frontend. For example, if your backend is running on api.mywebsite.com and your frontend on mywebsite.com, you might use:

    TYPESCRIPT
    domain: '.mywebsite.com'
  4. Cross-Origin Requests and Credentials
    Ensure that your Next.js frontend requests include credentials so that the browser sends (and receives) cookies. For instance, when using fetch or axios, you need to set:

    TYPESCRIPT
    // With fetch: fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', // important! }) // With axios: axios.defaults.withCredentials = true;

    Also, your Nest.js CORS config is correctly set with credentials: true, so ensure that the client request matches those requirements.

  5. Understanding HttpOnly Cookies
    Remember that an HttpOnly cookie isn’t accessible via JavaScript (which is good for security). This means that you won’t see it when you inspect client-side JavaScript; however, it should still be automatically sent with requests if you include credentials.

By checking these points—especially the secure flag, sameSite policy, and domain settings—you should be able to persist your refresh token cookie across page reloads and have it correctly handled by your Next.js frontend.

Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?

No comments yet.

Answer by PlutonianRanger544 1 month ago

0

This answer is based on the discussion in the comments under the question.

Issue in the question

We determined in the discussion that the backend and frontend run on different domains.

So in the backend when you do:

JAVASCRIPT
response.cookie('refreshToken', refreshToken, { path: '/', httpOnly: true, secure: this.configService.getOrThrow('ENVIRONMENT') === 'PRODUCTION', sameSite: true, maxAge: 7 * 24 * 60 * 60 * 1000, })

You're setting the cookie on the domain of the backend. Which is not what we want. To fix that issue we need to instead return that cookie in the backend and set it in the frontend server side (we'll get to that in a moment):

JAVASCRIPT
return { token: generatedAccessToken, user: userWithoutPassword, refreshToken }

Then in the frontend we set that cookie alongside the token itself:

JAVASCRIPT
setCookie(undefined, 'mywebsite-token', response.token, { maxAge: tokenExpiration, }) setCookie(undefined, 'mywebsite-refresh-token', response.refreshToken, { maxAge: tokenExpiration, })

This code however is insecure.

If you're setting sensitive information such as tokens in cookies. You must set them as http-only cookies:

JAVASCRIPT
setCookie(undefined, 'mywebsite-token', response.token, { maxAge: tokenExpiration, httpOnly: true }) setCookie(undefined, 'mywebsite-refresh-token', response.refreshToken, { maxAge: tokenExpiration, })

If you don't, then any javscript script in your frontend will be able to access the cookie through document.cookie. If a malicious user finds an XSS vulnerability in your code and manages to embed a script in your code. They'll be able to extract the cookie from any user that logs in.

That also means that your signIn function in NextJS must be ran server side. Luckily, it should be impossible to set an httpOnly cookie client side: How can I create secure/httpOnly cookies with document.cookie?

Alternative: Proxy the api

If you don't want to return the cookie as a JSON response and then set it in nextjs. You can use NextJS rewrites

JAVASCRIPT
module.exports = { async rewrites() { return [ { source: '/api', destination: 'http://backend-domain', }, ] }, }

Then direct any API requests in your frontend to frontend-domain/api instead of backend-domain/api.

Now you're free to use setCookie in your backend. The domains will match and your browser will happily set the cookie.

Downside: Every request to your backend has to go through your frontend. It adds load to your frontend. If you want to go this route and you expect a lot of traffic, it would be better to use a reverse proxy like nginx to do this rewrite.

Session vs. Token

A session is how we track a users presence on a website. This could be a logged in user, but could also be a non-logged in user. If we wish to distinguish every user.

A token is the identifier by which we recognize the user. We issue a token, either if the user doesn't have one (if we want to track guest access) or when a user logs in.

The token can be anything you want. The most common ones you'll see are:

  • An opaque uuid - sufficiently large that collisions are less likely to happen (2 users get the same token)
  • JWT - A non-opaque token that can be verified against a secret and contains information about the user you can decode. In your code I see the cookie token starts with eyJ which suggests a JWT.

Choosing between session token and jwt as session token

A session token is nice because it's opaque. It's a random string that doesn't contain any information about the user. But you must store the session token in some database and grab it from the database on every request to check if it's valid and who the user is.

The JWT is nice because you can avoid storing any state. Instead you grab the JWT from the cookie that's being sent, verify it. If it's valid you decode it. And it tells you who the user is.

Some people say it's better to use an opaque token because it's more secure. JWTs can be decoded at any time, even if it's expired and may contain personal information. You can use https://jwt.io/ to decode yours and see what's inside

I support the opinion that httpOnly is secure. And if someones able to get your JWT even though it's set to httpOnly you have bigger problems on your hand.

Another consideration: JWTs are much larger than opaque tokens. A UUID v4 is 36 characters, while a JWT is usually a few hundred characters. And since the user is sending this on every request, it could become a problem.

A note about refresh tokens

I mentioned that technically you don't need to send the refresh token to the user. Similarly to what I described in the previous section about storing session tokens in a database. You can do the same for refresh tokens. Like I said, I think it's fine to send JWTs as httpOnly only cookies. But if you really wanted to you could do it like this:

JAVASCRIPT
async login(...) { const token = this.authService.generateAccessToken(user.id) const refreshToken = this.authService.generateRefreshToken(user.id) await redis.storeRefreshToken(user.id, refreshToken) return { token, user: userWithoutPassword } } async refresh(...) { const refreshToken = await redis.getRefreshToken(user.id) const { sub, user } = await this.authService.validateRefreshToken(refreshToken) const generatedAccessToken = this.authService.generateAccessToken(sub) const generatedRefreshToken = this.authService.generateRefreshToken(sub) const { senha, ...userWithoutPassword } = user await redis.storeRefreshToken(user.id, generatedRefreshToken) return { token: generatedAccessToken, user: userWithoutPassword } }

You could also extend this and send an opaque token instead of the JWT:

JAVASCRIPT
async login(...) { const jwt = this.authService.generateAccessToken(user.id) const refreshToken = this.authService.generateRefreshToken(user.id) const opaqueToken = uuidv4() // from uuid@npm await Promise.all([ redis.storeJWT(user.id, jwt, opaqueToken) redis.storeRefreshToken(user.id, refreshToken) ]) return { token: opaqueToken, user: userWithoutPassword } }

No comments yet.

Discussion

No comments yet.