Registration and login
With the decisions made and the password rules in place, this lesson builds the working endpoints.
The schema
enum Role {
CUSTOMER
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
phone String?
role Role @default(CUSTOMER)
isActive Boolean @default(true)
createdAt DateTime @default(now())
refreshTokens RefreshToken[]
orders Order[]
@@map("users")
}
model RefreshToken {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@map("refresh_tokens")
}
tokenHash, not the token. A refresh token is a credential; a leaked
database should not hand over live sessions. Same reasoning as passwords.
role defaults to CUSTOMER. Never take a role from a request body — the
whitelist rule from module 7 exists partly for this.
onDelete: Cascade so deleting a user removes their tokens.
Registration
// apps/api/src/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
) {}
async register(dto: RegisterDto) {
const email = dto.email.trim().toLowerCase();
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) {
throw new ConflictException("An account with that email already exists");
}
const user = await this.prisma.user.create({
data: {
email,
name: dto.name.trim(),
passwordHash: await this.passwords.hash(dto.password),
},
select: { id: true, email: true, name: true, role: true },
});
return { user, ...(await this.tokens.issue(user.id, user.role)) };
}
}
Normalise the email. Priya@Example.com and priya@example.com are the
same account to a user and different strings to a database. Lowercase and trim
on the way in, always — otherwise somebody registers twice and cannot work out
why login fails.
Select explicitly, so passwordHash cannot escape.
A note on the existence check: it does tell a caller whether an email is registered, which the password lesson warned against. Registration is the one place this is hard to avoid — you have to tell someone their email is taken. The mitigations are rate limiting and, for a shop where the customer list is sensitive, sending a "someone tried to register with your address" email instead. For this project the plain message is acceptable, and knowing the trade-off is the point.
The @unique constraint is the real guard. Two simultaneous registrations both
pass the check and one fails at the database — which the Prisma filter from
module 7 turns into a clean 409.
Issuing tokens
// apps/api/src/auth/token.service.ts
import * as crypto from "node:crypto";
@Injectable()
export class TokenService {
constructor(
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {}
async issue(userId: string, role: Role) {
const accessToken = await this.jwt.signAsync(
{ sub: userId, role },
{
secret: this.config.getOrThrow<string>("JWT_SECRET"),
expiresIn: "15m",
},
);
const refreshToken = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await this.prisma.refreshToken.create({
data: { tokenHash: this.hash(refreshToken), userId, expiresAt },
});
return { accessToken, refreshToken };
}
hash(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
}
crypto.randomBytes, never Math.random(). Math.random() is predictable
and must never generate anything security-related — the rule from module 8 of
the Python course, in another language.
SHA-256 for the refresh token, not bcrypt. This is the one case where a fast hash is correct: the token is already 256 bits of randomness, so there is nothing to brute-force. Bcrypt's slowness protects low-entropy human passwords; here it would only make every refresh slow.
Login
async login(dto: LoginDto) {
const email = dto.email.trim().toLowerCase();
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await this.passwords.compare(dto.password, user.passwordHash))) {
throw new UnauthorizedException("Email or password is incorrect");
}
if (!user.isActive) {
throw new UnauthorizedException("This account has been disabled");
}
return {
user: { id: user.id, email: user.email, name: user.name, role: user.role },
...(await this.tokens.issue(user.id, user.role)),
};
}
One message for both failures, as established.
The controller and cookies
@Controller("auth")
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly config: ConfigService,
) {}
@Post("login")
@HttpCode(200)
@Throttle({ default: { limit: 5, ttl: 60_000 } })
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const { user, accessToken, refreshToken } = await this.auth.login(dto);
this.setCookies(res, accessToken, refreshToken);
return { user };
}
private setCookies(res: Response, accessToken: string, refreshToken: string) {
const isProduction = this.config.get("NODE_ENV") === "production";
const base = {
httpOnly: true,
secure: isProduction,
sameSite: "lax" as const,
path: "/",
};
res.cookie("access_token", accessToken, { ...base, maxAge: 15 * 60 * 1000 });
res.cookie("refresh_token", refreshToken, {
...base,
maxAge: 30 * 24 * 60 * 60 * 1000,
path: "/api/auth",
});
}
}
Three details.
@Res({ passthrough: true }) lets you set a cookie while still returning a
value normally. Without passthrough, taking the response object turns off
NestJS's serialisation — the warning from the controllers lesson.
The tokens go in cookies; only the user is in the body. The front end never
touches the token, which is the whole point of httpOnly.
path: "/api/auth" on the refresh cookie so it is only sent to the refresh
and logout endpoints. A cookie sent on every request has more chances to leak
into a log or a proxy.
secure: isProduction because secure cookies are not set over plain HTTP, so
local development would break.
Refresh
async refresh(token: string) {
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash: this.tokens.hash(token) },
include: { user: true },
});
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException("Please sign in again");
}
if (!stored.user.isActive) {
throw new UnauthorizedException("This account has been disabled");
}
// Rotate: this token is spent.
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
return this.tokens.issue(stored.user.id, stored.user.role);
}
Rotation. Each refresh token works once. If an already-revoked token is presented, something has gone wrong — the usual response is to revoke every token for that user, since either an attacker or the real user is using a stolen one.
Note the revocation path works here: an admin deleting a user's refresh tokens means their next refresh fails, so they are out within 15 minutes.
Logout
@Post("logout")
@HttpCode(204)
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token = req.cookies?.refresh_token;
if (token) await this.auth.revoke(token);
res.clearCookie("access_token", { path: "/" });
res.clearCookie("refresh_token", { path: "/api/auth" });
}
Both: delete the server-side record and clear the cookies. Clearing cookies alone leaves a working refresh token; revoking alone leaves a cookie that fails confusingly.
clearCookie must use the same path the cookie was set with, or it will not
match.
Reading the user
// apps/api/src/auth/strategies/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private readonly prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => req.cookies?.access_token ?? null,
]),
secretOrKey: config.getOrThrow<string>("JWT_SECRET"),
ignoreExpiration: false,
});
}
async validate(payload: { sub: string; role: Role }) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, name: true, role: true, isActive: true },
});
if (!user || !user.isActive) {
throw new UnauthorizedException();
}
return user;
}
}
validate reads the token from the cookie rather than the Authorization
header, which is what makes the httpOnly approach work.
This does hit the database on every request, which partly gives up the stateless advantage — in exchange for a disabled account taking effect immediately rather than in 15 minutes. That is the right trade for a shop, and it is a deliberate choice worth knowing you are making.
Check your work
Why normalise email to lowercase: users treat case as insignificant and databases do not, so without it people register twice and cannot log in.
Why store a hash of the refresh token: a leaked database should not hand over live sessions.
Why SHA-256 for refresh tokens but bcrypt for passwords: the refresh token is already high-entropy random, so there is nothing to brute-force; bcrypt's cost only protects guessable human input.
Why @Res({ passthrough: true }): it lets you set cookies while keeping
NestJS's normal return-value handling.
Why the refresh cookie has a narrower path: it is only needed at the auth endpoints, so it is sent less often and has fewer chances to leak.
What rotation gives you: a refresh token is single-use, so reuse of a spent one signals theft.
Why logout must do both: clearing cookies alone leaves a working token on the server.
What the database read in validate costs and buys: it gives up some
statelessness in exchange for disabled accounts taking effect immediately.
Practice
- Add the
UserandRefreshTokenmodels. Migrate. - Build registration. Confirm the response contains no
passwordHash. - Register with
Priya@Example.com, then try to log in aspriya@example.com. Fix it with normalisation. - Register the same email twice and confirm you get a 409.
- Build login. Inspect the cookies in DevTools and confirm both are
HttpOnly. - Try reading
document.cookiein the console and confirm the tokens are absent. - Set the access token's lifetime to 30 seconds. Wait, make a request, and confirm it fails. Refresh and confirm it works again.
- Use a refresh token twice and confirm the second attempt fails.
- Log out and confirm both the cookie and the database row are gone.
- Disable a user in Prisma Studio and confirm their next request fails immediately.
Next: guards and roles.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship