Part 4 of the Grimoire API series. So far: a validated endpoint (Part 1), persistence (Part 2), and the actual XP/badge rules (Part 3) — all running against one hardcoded "default player." This post finally gets rid of that placeholder.
Retiring DEFAULT_PLAYER_ID
Since Part 2, every POST /progress/choice call has quietly advanced the same fake user. It was a deliberate shortcut to build persistence and game logic without also building login at the same time — but it can't last. This milestone adds real accounts, and every progress endpoint gets scoped to whoever is actually authenticated.
Two new pieces: Passport and Guards
NestJS doesn't reinvent authentication — it wraps Passport, the well-established Node auth library, behind its own decorators. Two Passport strategies cover what this project needs:
- Local strategy — email + password, used once, at login, to issue a token
- JWT strategy — validates a bearer token on every subsequent request
A Guard is what actually blocks a request before it reaches a controller method, if authentication fails:
// src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
Barely any code — AuthGuard('jwt') from @nestjs/passport does the real work; this class just names which strategy to use.
Hashing passwords
Never store a plain password. bcrypt handles hashing on sign-up and comparison on login:
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { User } from '../users/entities/user.entity';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User) private readonly userRepo: Repository<User>,
private readonly jwtService: JwtService,
) {}
async register(email: string, password: string): Promise<User> {
const passwordHash = await bcrypt.hash(password, 10);
return this.userRepo.save(this.userRepo.create({ email, passwordHash }));
}
async login(email: string, password: string): Promise<{ accessToken: string }> {
const user = await this.userRepo.findOneBy({ email });
const valid = user && (await bcrypt.compare(password, user.passwordHash));
if (!valid) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.jwtService.sign({ sub: user.id }) };
}
}
The 10 in bcrypt.hash(password, 10) is the cost factor — how many rounds of hashing, trading speed for resistance to brute-force attempts. 10 is a reasonable default for a learning project; production tuning is its own topic.
Note the deliberately vague error: 'Invalid credentials' regardless of whether the email doesn't exist or the password is wrong. Being specific ("no such email") tells an attacker which emails are registered — a small leak, but a real one.
The JWT strategy
// src/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: { sub: string }) {
return { id: payload.sub };
}
}
Whatever validate() returns becomes request.user on every guarded request. Here it's deliberately minimal — just the user ID from the token's sub claim — because that's all the rest of the app actually needs.
A custom decorator: @CurrentUser()
Reaching into request.user by hand in every controller gets repetitive fast. NestJS lets you wrap that pattern in your own decorator:
// src/auth/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user;
},
);
And now the progress controller from Part 2 loses its placeholder for good:
@UseGuards(JwtAuthGuard)
@Post('choice')
async advance(@CurrentUser() user: { id: string }, @Body() dto: AdvanceProgressDto) {
return this.progressService.advance(user.id, dto.nextPageId);
}
@UseGuards(JwtAuthGuard) rejects the request with a 401 before the method body ever runs if the token is missing or invalid — the same "reject before your code even sees it" pattern as the ValidationPipe from Part 1, just guarding who instead of what shape of data.
Where I expect to get stuck
I haven't built this milestone yet, but I can already name the likely trap from experience reading about it: forgetting that @Injectable() services created per request (none of mine are, so far) behave differently from the request-scoped nature of request.user. As long as CurrentUser stays a decorator that reads straight from the request object — rather than something cached on a singleton service — this should be a non-issue. Worth double-checking once real tests are in place, in Part 5.
What's next
Every meaningful endpoint is now behind a real login. Part 5 closes the loop on quality: a proper test suite for the XP/badge logic, a global exception filter so error responses are consistent everywhere, and a look at Pipes and Interceptors beyond the validation pipe from Part 1.
Top comments (0)