May 28, 2026
Secure login system in NestJS.
A NestJS login broken down piece by piece: bcrypt, JWT, httpOnly cookie, and the security decisions hidden in each step.
I built this login in NestJS from scratch, as a study project, to understand every decision instead of copying from a tutorial. The part I found most interesting were the details a ready-made login hides: why the token goes in a cookie and not in localStorage, why the two login error messages are identical, and why I query the database on every request even while using JWT.
The stack is NestJS 11, Passport with passport-jwt, bcrypt for hashing, Prisma with MariaDB. Stateless JWT, token in a cookie.
Registration
On registration, the plain-text password has to become a hash before it reaches the database. I use bcrypt for that:
const hashedPassword = await bcrypt.hash(data.password, 10);
The 10 is the number of salt rounds. Each round doubles the cost of computing the hash, so 10 means 2^10 iterations. The more rounds, the slower the login gets, both for a regular user and for anyone trying to crack the hashes in bulk. Ten is a common value because it balances both sides: fast enough for a login, slow enough to get in the way of a brute-force attack.
One thing that confused me at first: bcrypt generates the salt on its own and stores it inside the hash itself. It doesn't need a separate column in the database. If you look at the generated hash, the salt is right there at the start of the string.
After that the user is created and I return only what's needed:
return { id: user.id, email: user.email, name: user.name };
No password, no hash. Registration also doesn't log the user in automatically, it just creates the account.
Login
On login, two things can go wrong: the email doesn't exist, or the password is wrong. The instinct is to respond to each case differently, something like "email not found" and "incorrect password." But that helps an attacker.
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');
}
The two messages are identical on purpose. If they were different, you could sweep the API with a list of emails and find out which ones have an account, just by looking at which message comes back. This is called user enumeration, and it serves as the base for later attacks, like targeted phishing or brute force aimed only at accounts that exist. With the generic message, the attacker can't tell the two cases apart.
On registration I do the opposite, I say it straight that the email already exists:
if (error.code === 'P2002') {
throw new ConflictException('Email já cadastrado.');
}
Here hiding it wouldn't help, because the attacker would find out the same thing just by trying to create the account. And hiding it would make the experience worse for someone who only wants to know they already have an account. That's why the two endpoints make opposite decisions, even though they look similar.
When the credentials match, the token is signed:
const payload = { sub: user.id, email: user.email };
const token = this.jwtService.sign(payload);
The payload has only the user id (in sub, which is the JWT convention for identifying the token's owner) and the email. What goes in here travels inside the token all the way to the client, so I avoid putting anything sensitive in it.
Where to store the token
With the token signed, the question is where to store it. This was the decision that affects security the most in the project.
The most common approach in tutorials is to return the token in the JSON and let the frontend save it in localStorage. The problem is that any JavaScript on the page can read localStorage. If the site has an XSS flaw, an injected script can read the token and send it out, which gives access to the user's account.
That's why I send the token in an httpOnly cookie:
response.cookie('access_token', token, {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
The httpOnly: true solves the localStorage problem: the cookie is invisible to JavaScript, document.cookie doesn't see it. Even with an XSS flaw, the script can't read the token. The browser keeps sending the cookie on requests by itself, but the page's code never touches it.
The sameSite: 'lax' helps against CSRF, because the browser only sends the cookie on requests that come from the site itself.
The maxAge matches the token's expiration on purpose, seven days for both. If the cookie lasts longer than the token, the browser keeps sending an already-expired JWT on every request and the user gets 401s until they log out by hand. It's that "my login drops on its own" bug.
The secure: false is the weak point. It's fixed because the project runs on HTTP in development, but in production it would have to be true so the cookie only travels over HTTPS. The right thing would be to not hardcode it at all, but set it based on the environment. I'm being clear about it because this is study material, not production.
With the token in the cookie, the response body doesn't need to carry it:
return { message: 'Login realizado com sucesso' };
Validating the token on every request
User logged in, cookie in the browser. When they access a protected route, the token is verified before letting them through. The route uses a guard:
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthUser) {
return user;
}
The guard triggers Passport, which first needs to find the token. Since it's in a cookie and not in the Authorization header, I wrote an extractor to pull it from the cookie:
jwtFromRequest: ExtractJwt.fromExtractors([
(req) => req?.cookies?.access_token,
]),
ignoreExpiration: false,
That's why cookie-parser has to be registered, otherwise req.cookies doesn't even exist. The ignoreExpiration: false makes expired tokens get rejected.
Passport checks the signature and the expiration and, if it passes, calls validate(). This is the part I most wanted to understand when I started:
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 };
}
Most JWT-with-Passport examples stop at the signature: if the token is valid and hasn't expired, they trust it and move on, without touching the database. That's the point of stateless JWT, not having to query anything.
I query the database anyway. It seems to contradict the stateless idea, but it solves a concrete case: a deleted or banned user. If I trusted only the signature, the token of a deleted account would keep working for the full seven days until it expired, because the token doesn't know the account is gone. By querying the database, an account that no longer exists is blocked on the next request. It's a trade-off: I give up the performance gain of stateless in exchange for consistency. On a small project the extra query doesn't weigh much. On a large system I'd reconsider, maybe with a cache.
The select also brings back only id and email, not the password or other fields. This return value goes into request.user, and /me returns it directly, so the fewer fields the better, to avoid leaking anything by accident.
The return of validate() goes into request.user, and a simple decorator makes it accessible in the controller:
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
This avoids pulling request.user by hand in every method. I write @CurrentUser() user and I have the user.
Logout
Logout only clears the cookie:
res.clearCookie('access_token', { path: '/' });
return { ok: true };
The token itself stays valid until it expires, because a stateless JWT can't be invalidated on the server without an extra layer, like a blacklist. Without the cookie, the token falls out of the normal flow, but if it had leaked beforehand, clearing the cookie doesn't deactivate it. Solving that would require refresh tokens with rotation or a list of revoked tokens, which are out of scope for a study project.
What was left out
This project has no refresh token, no rate limiting on login, and doesn't validate environment variables at startup (if JWT_SECRET is missing, the app boots and only breaks when it tries to sign the token). Input validation is also done by hand in the service, instead of using class-validator on the DTOs.
I left it this way on purpose, because the goal was to understand the authentication flow, not to cover every production case. But I list it because I think knowing what's missing is part of understanding what was built.
What I learned
What caught my attention the most was how much security depends on a single line: the identical error message on login, the httpOnly on the cookie, the lean select in validate(). None of them is hard to write, and all of them are easy to forget.
It also became clearer why tutorials tend to teach the less secure version. Saving the token in localStorage is one line. Doing it right with an httpOnly cookie requires understanding XSS, CSRF, and how the cookie works. Building from scratch forced me to go through each of those decisions instead of accepting the default without knowing why.