DEV Community

Rodolphe D.
Rodolphe D.

Posted on

Building a Choose-Your-Own-Adventure API with NestJS — Part 4: Auth

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') {}
Enter fullscreen mode Exit fullscreen mode

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 }) };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  },
);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

@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)