andré santos
posts

28 de maio de 2026

Sistema de login seguro em NestJS.

Um login em NestJS desmontado peça por peça: bcrypt, JWT, cookie httpOnly e as decisões de segurança escondidas em cada etapa.

Construí esse login em NestJS do zero, como projeto de estudo, pra entender cada decisão em vez de copiar de tutorial. A parte que achei mais interessante foram os detalhes que um login pronto esconde: por que o token vai num cookie e não no localStorage, por que as duas mensagens de erro do login são iguais, e por que eu consulto o banco em toda request mesmo usando JWT.

A stack é NestJS 11, Passport com passport-jwt, bcrypt pra hash, Prisma com MariaDB. JWT stateless, token em cookie.

Registro

No registro, a senha em texto puro precisa virar um hash antes de ir pro banco. Uso bcrypt pra isso:

const hashedPassword = await bcrypt.hash(data.password, 10);

O 10 é o número de salt rounds. Cada round dobra o custo de calcular o hash, então 10 são 2^10 iterações. Quanto mais rounds, mais lento fica o login, tanto pro usuário normal quanto pra quem tentaria quebrar os hashes em massa. Dez é um valor comum porque equilibra os dois lados: rápido o bastante pra um login, lento o bastante pra atrapalhar ataque por força bruta.

Uma coisa que me confundiu no começo: o bcrypt gera o salt sozinho e guarda ele dentro do próprio hash. Não precisa de uma coluna separada no banco. Se você olhar o hash gerado, o salt está lá no começo da string.

Depois disso o usuário é criado e eu retorno só o necessário:

return { id: user.id, email: user.email, name: user.name };

Sem senha, sem hash. O registro também não loga o usuário automaticamente, só cria a conta.

Login

No login, duas coisas podem dar errado: o email não existe ou a senha está errada. O instinto é responder cada caso de um jeito, tipo "email não encontrado" e "senha incorreta". Mas isso ajuda um atacante.

const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
  throw new UnauthorizedException('Credenciais inválidas');
}

const passwordMatch = await bcrypt.compare(data.password, user.password);
if (!passwordMatch) {
  throw new UnauthorizedException('Credenciais inválidas');
}

As duas mensagens são iguais de propósito. Se fossem diferentes, daria pra varrer a API com uma lista de emails e descobrir quais têm conta, só olhando qual mensagem volta. Isso se chama enumeração de usuário, e serve de base pra ataques depois, como phishing direcionado ou força bruta só nas contas que existem. Com a mensagem genérica, o atacante não consegue diferenciar os dois casos.

No registro eu faço o contrário, falo na cara que o email já existe:

if (error.code === 'P2002') {
  throw new ConflictException('Email já cadastrado.');
}

Aqui esconder não adiantaria, porque o atacante descobriria a mesma coisa só tentando criar a conta. E esconder pioraria a experiência de quem só quer saber que já tem cadastro. Por isso os dois endpoints tomam decisões opostas, mesmo sendo parecidos.

Quando as credenciais batem, o token é assinado:

const payload = { sub: user.id, email: user.email };
const token = this.jwtService.sign(payload);

O payload tem só o id do usuário (no sub, que é a convenção do JWT pra identificar o dono do token) e o email. O que entra aqui viaja dentro do token até o cliente, então evito colocar qualquer coisa sensível.

Onde guardar o token

Com o token assinado, a pergunta é onde guardar ele. Essa foi a decisão que mais mexe com segurança no projeto.

O jeito mais comum em tutoriais é devolver o token no JSON e deixar o frontend salvar no localStorage. O problema é que qualquer JavaScript da página lê o localStorage. Se o site tiver uma falha de XSS, um script injetado consegue ler o token e mandar pra fora, o que dá acesso à conta do usuário.

Por isso mando o token num cookie httpOnly:

response.cookie('access_token', token, {
  httpOnly: true,
  secure: false,
  sameSite: 'lax',
  path: '/',
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

O httpOnly: true resolve o problema do localStorage: o cookie fica invisível pro JavaScript, o document.cookie não enxerga. Mesmo com uma falha de XSS, o script não consegue ler o token. O navegador continua mandando o cookie nas requests sozinho, mas o código da página nunca acessa ele.

O sameSite: 'lax' ajuda contra CSRF, porque o navegador só manda o cookie em requests que saem do próprio site.

O maxAge é igual à expiração do token de propósito, sete dias nos dois. Se o cookie durar mais que o token, o navegador fica mandando um JWT já vencido em toda request e o usuário toma 401 até deslogar na mão. É aquele bug de "meu login cai sozinho".

O secure: false é o ponto fraco. Está fixo porque o projeto roda em HTTP no desenvolvimento, mas em produção teria que ser true pra o cookie só trafegar em HTTPS. O certo seria nem deixar fixo, e sim definir pelo ambiente. Deixo claro porque isso é coisa de estudo, não de produção.

Com o token no cookie, o corpo da resposta não precisa carregar ele:

return { message: 'Login realizado com sucesso' };

Validando o token em cada request

Usuário logado, cookie no navegador. Quando ele acessa uma rota protegida, o token é verificado antes de passar. A rota usa um guard:

@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthUser) {
  return user;
}

O guard aciona o Passport, que primeiro precisa achar o token. Como ele está num cookie e não no header Authorization, escrevi um extrator pra pegar do cookie:

jwtFromRequest: ExtractJwt.fromExtractors([
  (req) => req?.cookies?.access_token,
]),
ignoreExpiration: false,

Por isso o cookie-parser precisa estar registrado, senão req.cookies nem existe. O ignoreExpiration: false faz tokens vencidos serem rejeitados.

O Passport confere a assinatura e a expiração e, se passar, chama o validate(). Essa é a parte que eu mais queria entender quando comecei:

async validate(payload: JwtPayload) {
  const user = await this.prismaService.user.findUnique({
    where: { id: payload.sub },
    select: { id: true, email: true },
  });
  if (!user) throw new UnauthorizedException('Usuário não encontrado');
  return { userId: user.id, email: user.email };
}

A maioria dos exemplos de JWT com Passport para na assinatura: se o token é válido e não venceu, confia e segue, sem tocar no banco. Esse é o ponto do JWT stateless, não precisar consultar nada.

Eu consulto o banco assim mesmo. Parece contradizer o stateless, mas resolve um caso concreto: usuário deletado ou banido. Se eu confiasse só na assinatura, o token de uma conta excluída continuaria funcionando pelos sete dias até vencer, porque o token não sabe que a conta sumiu. Consultando o banco, a conta que não existe mais é barrada na request seguinte. É um trade-off: troco o ganho de performance do stateless por consistência. Num projeto pequeno a consulta extra não pesa. Num sistema grande eu reconsideraria, talvez com cache.

O select também traz só id e email, não a senha nem outros campos. Esse retorno vai pro request.user, e o /me devolve ele direto, então quanto menos campo melhor pra não vazar nada sem querer.

O retorno do validate() vai pro request.user, e um decorator simples deixa ele acessível no controller:

export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);

Isso evita ficar puxando request.user na mão em cada método. Escrevo @CurrentUser() user e tenho o usuário.

Logout

O logout só limpa o cookie:

res.clearCookie('access_token', { path: '/' });
return { ok: true };

O token em si continua válido até vencer, porque um JWT stateless não tem como ser invalidado no servidor sem uma camada a mais, tipo uma blacklist. Sem o cookie, o token sai do fluxo normal, mas se ele tivesse vazado antes, limpar o cookie não desativa ele. Resolver isso exigiria refresh token com rotação ou uma lista de tokens revogados, que ficam fora do escopo de um projeto de estudo.

O que ficou de fora

Esse projeto não tem refresh token, não tem rate limiting no login e não valida as variáveis de ambiente no startup (se faltar o JWT_SECRET, a aplicação sobe e só quebra na hora de assinar o token). A validação dos dados de entrada também está feita na mão no service, em vez de usar class-validator nos DTOs.

Deixei assim de propósito, porque o objetivo era entender o fluxo de autenticação, não cobrir todos os casos de produção. Mas listo porque acho que saber o que falta faz parte de entender o que foi feito.

O que aprendi

O que mais me chamou atenção foi quanta coisa de segurança depende de uma linha só: a mensagem de erro igual no login, o httpOnly no cookie, o select enxuto no validate(). Nenhuma é difícil de escrever, e todas são fáceis de esquecer.

Também ficou mais claro por que tutorial costuma ensinar a versão menos segura. Salvar o token no localStorage é uma linha. Fazer certo com cookie httpOnly exige entender XSS, CSRF e como o cookie funciona. Construir do zero me obrigou a passar por cada uma dessas decisões em vez de aceitar o padrão sem saber por quê.

Sistema de login seguro em NestJS. - André Luiz