[NestJS] JWT 로그인 구현
NestJS 강의 내용을 정리한 글이다.
1. 패키지 설치
JWT 인증을 구현하려면 Passport 코어, NestJS 어댑터, JWT 전략 패키지를 설치해야 한다. bcrypt는 회원가입 편에서 이미 설치되어 있다고 가정한다.
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
| 패키지 | 역할 |
|---|---|
passport |
Node.js 인증 미들웨어 코어. Strategy 패턴을 제공한다. |
@nestjs/passport |
Passport를 NestJS DI 컨테이너와 통합하는 어댑터. PassportStrategy와 AuthGuard를 제공한다. |
passport-jwt |
JWT 토큰을 검증하는 Passport 전략 구현체. Strategy와 ExtractJwt를 제공한다. |
@nestjs/jwt |
JWT 서명·검증을 담당하는 NestJS 전용 래퍼 모듈. JwtService.sign()을 DI로 주입받아 사용한다. |
@types/passport-jwt |
passport-jwt의 TypeScript 타입 선언 파일. devDependency로 설치한다. |
2. 로그인 흐름
로그인 요청이 들어왔을 때 서버가 JWT를 발급하기까지의 순서다. 각 단계에서 실패하면 동일하게 UnauthorizedException을 던져 공격자가 어느 단계에서 실패했는지 알 수 없게 한다.
- username으로 DB 조회 —
userRepository.findOneBy({ username })로 사용자를 찾는다. 없으면null이 반환된다. - bcrypt.compare()로 비밀번호 검증 — 클라이언트가 보낸 평문 비밀번호와 DB에 저장된 해시를 비교한다. 복호화가 아니라 동일 방식으로 재해싱해 비교한다. 결과는 boolean이다.
- jwtService.sign()으로 토큰 발급 — 검증 성공 시
{ username }을 payload로 서명한다. 모듈 등록 시 설정한 secret과expiresIn이 자동으로 적용된다. - { accessToken } 반환 — 클라이언트는 이 토큰을 저장하고 이후 요청의
Authorization: Bearer <token>헤더에 첨부한다.
사용자가 없는 경우와 비밀번호가 틀린 경우를 별도 메시지로 구분하지 않는다. "login failed"라는 동일 메시지를 내보내야 사용자 존재 여부가 외부에 노출되지 않는다.
3. AuthService.signIn() 구현
AuthService에 signIn() 메서드를 추가한다. JwtService를 생성자로 주입받고, 반환 타입은 Promise<{ accessToken: string }>로 명시한다.
// auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { User } from './user.entity';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private jwtService: JwtService,
) {}
async signIn(authCredentialsDto: AuthCredentialsDto): Promise<{ accessToken: string }> {
const { username, password } = authCredentialsDto;
const user = await this.userRepository.findOneBy({ username });
if (user && (await bcrypt.compare(password, user.password))) {
const payload = { username };
const accessToken = this.jwtService.sign(payload);
return { accessToken };
} else {
throw new UnauthorizedException('login failed');
}
}
}
findOneBy({ username }) vs findOne({ username })
강의 원본의findOne({ username })형태는 TypeORM 0.3에서 deprecated되었다. 현재 버전에서는 반드시findOneBy({ username })또는findOne({ where: { username } })를 사용해야 한다.
4. JwtStrategy 구현
JwtStrategy는 보호된 라우트에 접근할 때 동작한다. AuthGuard('jwt')가 요청 헤더에서 토큰을 추출하고 서명을 검증한 뒤, 유효하면 validate(payload)를 호출한다.
PassportStrategy(Strategy)를 상속하고, super()에 두 가지 옵션을 반드시 전달해야 한다.
jwtFromRequest— 요청에서 토큰을 어떻게 꺼낼지 지정한다.ExtractJwt.fromAuthHeaderAsBearerToken()은Authorization: Bearer <token>헤더에서 추출한다.secretOrKey— 토큰 서명 검증에 사용할 secret. 환경변수에서 주입받는다.
// jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private configService: ConfigService,
) {
super({
secretOrKey: configService.getOrThrow('JWT_SECRET'),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
});
}
async validate(payload: { username: string }): Promise<User> {
const { username } = payload;
const user = await this.userRepository.findOneBy({ username });
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
validate()의 역할
Passport는 validate()를 암호학적 서명 검증이 완료된 뒤에 호출한다. 이 시점에서 토큰이 위조되지 않았음은 이미 보장된 상태다. validate()의 책임은 payload가 가리키는 사용자가 실제로 DB에 존재하는지 확인하는 것이다.
validate()가 반환한 User 객체는 Passport가 자동으로 request.user에 주입한다. 이후 컨트롤러에서 @Req() req로 꺼내거나 커스텀 데코레이터를 통해 접근할 수 있다. 탈퇴한 사용자의 유효 토큰을 차단하는 로직도 여기에 추가하면 된다.
| 단계 | 담당 | 처리 내용 |
|---|---|---|
| 헤더 추출 | ExtractJwt |
Authorization: Bearer 헤더에서 토큰 문자열을 꺼낸다. |
| 서명 검증 | passport-jwt |
secretOrKey로 서명을 검증하고 만료 여부를 확인한다. |
| 사용자 복원 | validate() |
payload의 username으로 DB를 조회해 User 객체를 반환한다. |
| 주입 | Passport 코어 | validate() 반환값을 request.user에 자동으로 주입한다. |
5. AuthModule 설정
AuthModule에 PassportModule과 JwtModule을 추가한다. JwtModule.registerAsync를 사용하면 ConfigService가 초기화된 뒤 secret을 주입받으므로 하드코딩 없이 환경변수를 안전하게 분리할 수 있다.
// auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: { expiresIn: 3600 },
}),
}),
TypeOrmModule.forFeature([User]),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [JwtStrategy, PassportModule],
})
export class AuthModule {}
PassportModule.register({ defaultStrategy: 'jwt' })
이 설정을 추가하면 @UseGuards(AuthGuard())처럼 전략 이름을 인자 없이 호출해도 기본으로 JWT 전략이 사용된다. 문자열 리터럴 'jwt'를 반복하지 않아도 되므로 오타 위험이 줄어든다.
exports: [JwtStrategy, PassportModule]이 왜 필요한가
AuthGuard('jwt')로 보호된 라우트는 AuthModule 외부에도 존재한다. 예를 들어 BoardsModule의 컨트롤러에서 @UseGuards(AuthGuard('jwt'))를 사용하려면 JwtStrategy와 PassportModule이 BoardsModule의 DI 컨텍스트에도 존재해야 한다.
exports에 포함하면 AuthModule을 import한 모든 모듈이 이 두 제공자를 사용할 수 있게 된다. 누락 시 Nest can't resolve dependencies of the JwtAuthGuard 또는 Unknown authentication strategy "jwt" 에러가 발생한다.
exports 누락 증상
서버 실행 시점이 아니라 보호된 라우트를 처음 호출할 때 에러가 발생하기 때문에 놓치기 쉽다.AuthModule을 import하는 모든 모듈에서AuthGuard를 사용할 계획이라면 처음부터 exports를 추가하는 것이 안전하다.
6. 동작 검증
서버를 실행한 뒤 아래 순서로 curl을 실행해 토큰 발급 흐름을 검증한다.
회원가입
curl -X POST localhost:3000/auth/signup \
-H 'Content-Type: application/json' \
-d '{"username":"test","password":"Pass1234"}'
로그인 — accessToken 발급 확인
curl -X POST localhost:3000/auth/signin \
-H 'Content-Type: application/json' \
-d '{"username":"test","password":"Pass1234"}'
성공 시 응답 예시.
잘못된 비밀번호 — 401 확인
curl -X POST localhost:3000/auth/signin \
-H 'Content-Type: application/json' \
-d '{"username":"test","password":"WrongPass"}'
토큰 내용 확인
발급받은accessToken을 jwt.io에 붙여넣으면 payload에username,iat(발급 시각),exp(만료 시각)가 포함되어 있음을 확인할 수 있다. Header와 Payload는 Base64로 인코딩되어 있을 뿐 암호화된 것이 아니므로 민감한 정보(비밀번호, 개인정보 등)를 payload에 포함해서는 안 된다.
7. 정리
Passport + JWT 조합으로 stateless 인증을 완성했다. 로그인 시 서버가 세션을 저장하지 않고 클라이언트가 토큰을 보관한다. 이후 모든 인증은 토큰의 서명 검증만으로 처리되므로 서버를 수평으로 확장해도 인증 상태를 공유할 필요가 없다.
세 파일의 역할 분리를 명확히 기억하면 전체 흐름이 보인다. AuthService는 비즈니스 로직(bcrypt 비교 + 토큰 발급)을 담당하고, JwtStrategy는 토큰에서 사용자를 복원하는 Passport 어댑터 역할을 하며, AuthModule은 이 둘을 묶고 다른 모듈에 Passport 컨텍스트를 내보낸다.
다음 편에서는 @UseGuards(AuthGuard())로 라우트를 보호하고, 커스텀 데코레이터로 request.user를 편리하게 꺼내는 방법을 다룬다.