O problema que o tutorial não mostra
Você implementou autenticação com JWT. O token tem 15 minutos de expiração. O usuário clica em "sair" e o frontend deleta o token do localStorage. Parece funcionar.
Mas o token ainda é válido por até 15 minutos. Se alguém capturou esse token (XSS, interceptação, log acidental), ele pode continuar usando a API durante esse período. E se o usuário mudar a senha por suspeita de comprometimento, o token antigo continua funcionando.
Esse é o custo real do JWT stateless: você troca complexidade de infraestrutura (sessões no banco/Redis) por vulnerabilidades de revogação.
O que faz um JWT ser válido
Um JWT tem três partes: header, payload e assinatura. O servidor valida a assinatura com sua chave secreta e confia no conteúdo do payload sem consultar nenhum estado externo.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. ← header
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxNTAwMDAwMCwiZXhwIjoxNzE1MDAwOTAwfQ. ← payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← assinatura
O campo exp define a expiração. Enquanto exp estiver no futuro e a assinatura for válida, o token é aceito — independentemente de qualquer outra coisa que tenha acontecido com o usuário.
Access token + refresh token
A solução padrão:
- Access token: vida curta (5–15 minutos). Stateless. Enviado em cada request.
- Refresh token: vida longa (dias/semanas). Stateful (armazenado no banco). Usado apenas para obter novos access tokens.
// auth.service.ts
import { JwtService } from '@nestjs/jwt';
import { randomBytes } from 'crypto';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly refreshTokenRepository: RefreshTokenRepository,
) {}
async generateTokens(userId: string) {
const accessToken = this.jwtService.sign(
{ sub: userId },
{ expiresIn: '15m' },
);
const refreshToken = randomBytes(40).toString('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 dias
await this.refreshTokenRepository.create({
token: refreshToken, // ou o hash: await bcrypt.hash(refreshToken, 10)
userId,
expiresAt,
});
return { accessToken, refreshToken };
}
}O schema do refresh token:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(id),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token)
WHERE revoked_at IS NULL;Rotação de refresh tokens
Cada vez que o refresh token é usado para obter um novo access token, você deve:
- Invalidar o refresh token atual
- Emitir um novo refresh token junto com o novo access token
Isso é chamado de refresh token rotation. O benefício: se um refresh token for roubado e usado, você detecta o double-use — dois clients tentando usar o mesmo token — e pode revogar toda a família de sessão.
async refreshTokens(refreshToken: string) {
const stored = await this.refreshTokenRepository.findValid(refreshToken);
if (!stored) {
// Token inválido, expirado ou já revogado
// Se foi revogado anteriormente, pode ser reuse attack — revogar todas as sessões do usuário
await this.handlePossibleTokenReuse(refreshToken);
throw new UnauthorizedException('Refresh token inválido.');
}
// Revoga o token atual (rotação)
await this.refreshTokenRepository.revoke(stored.id);
// Emite novos tokens
return this.generateTokens(stored.userId);
}
private async handlePossibleTokenReuse(token: string) {
const revoked = await this.refreshTokenRepository.findRevoked(token);
if (revoked) {
// Alguém usou um token já revogado — possível comprometimento
await this.refreshTokenRepository.revokeAllForUser(revoked.userId);
// Opcional: notificar o usuário
}
}Revogação de access tokens
O problema fundamental: access tokens de vida curta não são revogáveis por natureza stateless.
Para revogar antes da expiração, a opção mais comum é uma blocklist no Redis:
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly redisService: RedisService,
private readonly configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: { sub: string; iat: number; exp: number }) {
const isRevoked = await this.redisService.exists(`revoked:${payload.sub}:${payload.iat}`);
if (isRevoked) throw new UnauthorizedException('Token revogado.');
return { id: payload.sub };
}
}Ao fazer logout ou revogar sessão:
async logout(userId: string, tokenIat: number, tokenExp: number) {
const ttl = tokenExp - Math.floor(Date.now() / 1000); // segundos restantes
if (ttl > 0) {
await this.redisService.setex(`revoked:${userId}:${tokenIat}`, ttl, '1');
}
await this.refreshTokenRepository.revokeAllForUser(userId);
}O iat (issued at) é o identificador do token individual. A chave no Redis expira junto com o token, então a blocklist não cresce indefinidamente.
Armazenamento seguro no cliente
Onde guardar o refresh token no frontend importa muito:
| Opção | XSS | CSRF | Recomendação |
|---|---|---|---|
| localStorage | Vulnerável | Imune | Não usar para refresh token |
| Cookie HttpOnly + Secure | Imune | Vulnerável | Usar com proteção CSRF |
| Cookie HttpOnly + SameSite=Strict | Imune | Imune (mesma origem) | Melhor opção |
| Memória (variável JS) | Imune | Imune | Perde no refresh da página |
A configuração recomendada para o cookie de refresh token:
// auth.controller.ts
@Post('refresh')
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const refreshToken = req.cookies['refresh_token'];
const tokens = await this.authService.refreshTokens(refreshToken);
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true,
secure: true, // só HTTPS
sameSite: 'strict', // só mesma origem
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias
path: '/auth/refresh', // só disponível para esse endpoint
});
return { accessToken: tokens.accessToken };
}O access token pode ir no corpo da resposta e ser guardado em memória no frontend. Ele expira em 15 minutos de qualquer forma — não vale o risco de colocar no localStorage.
Mudança de senha invalida tokens
Quando o usuário muda a senha, todos os refresh tokens devem ser revogados:
async changePassword(userId: string, oldPassword: string, newPassword: string) {
// Valida senha atual, faz o hash da nova...
await this.userRepository.updatePassword(userId, hashedPassword);
// Invalida todas as sessões ativas
await this.refreshTokenRepository.revokeAllForUser(userId);
// Para invalidar access tokens em andamento, você pode incluir
// um campo "password_changed_at" no JWT e verificar no validate()
}Uma técnica eficiente sem blocklist: incluir o hash da senha (ou um version que incrementa a cada mudança de senha) no JWT, e no validate() comparar com o valor atual no banco. Se divergir, o token é inválido. Isso adiciona uma query por request, mas elimina a necessidade de blocklist para esse caso específico.
Resumo das decisões
| Decisão | Recomendação | |---|---| | Expiração do access token | 5–15 minutos | | Expiração do refresh token | 7–30 dias | | Rotação de refresh token | Sempre | | Revogação de access token | Blocklist Redis com TTL | | Armazenamento do refresh token | Cookie HttpOnly + Secure + SameSite | | Armazenamento do access token | Memória (ou cookie separado) | | Mudança de senha | Revogar todos os refresh tokens |
JWT stateless é uma troca, não uma solução mágica. Se você precisa de revogação instantânea em todos os cenários, sessões com estado (Redis) são mais simples. Se você aceita a janela de expiração do access token (5–15 min), JWT com refresh token rotacionado é uma escolha sólida.