My backend sends a refresh token as a cookie after the login happens.
Although this cookie does not get persisted after closing or refreshing my Next.js
page.
I am sending the refresh token cookie after loginIn as a server side cookie so its secure.
This is the login
logic:
@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 }
}
Main.js
of Nest.js:
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 in I get the normal cookie and the refresh token (that was sent from the backend, this refresh token gets lost after refreshing, making not possible to refresh):
The login logic in Next.js
frontend:
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')
}
This answer is based on the discussion in the comments under the question.
We determined in the discussion that the backend and frontend run on different domains.
So in the backend when you do:
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):
return {
token: generatedAccessToken,
user: userWithoutPassword,
refreshToken
}
Then in the frontend we set that cookie alongside the token itself:
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:
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?
If you don't want to return the cookie as a JSON response and then set it in nextjs. You can use NextJS rewrites
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.
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:
eyJ
which suggests a JWT.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.
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:
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:
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 }
}