NestJS Passport 통합
NestJS에서 인증을 구현하려면 JWT 토큰 검증, 세션 관리, OAuth 콜백 처리 등 다양한 인증 전략을 다뤄야 한다. 이걸 매번 Guard 안에서 직접 구현하면 어떻게 될까?
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
const payload = jwt.verify(token, SECRET_KEY);
request.user = payload;
return true;
}
}
JWT 하나만 쓸 때는 이걸로 충분하다. 그런데 리프레시 토큰도 검증해야 하고, Google OAuth도 붙이고, GitHub 로그인도 추가해야 한다면? 각각의 Guard마다 토큰 추출 → 검증 → 사용자 정보 부착이라는 동일한 흐름을 반복하게 된다. 차이는 "토큰을 어디서 가져오는지", "어떤 비밀키로 검증하는지"뿐인데 전체 구조를 매번 다시 짜야 한다.
Passport는 이 문제를 Strategy 패턴으로 해결한다. "인증 흐름"이라는 틀은 Passport가 관리하고, "구체적인 인증 방식"만 Strategy로 구현하면 된다. @nestjs/passport는 이 Passport를 NestJS의 Guard/DI 체계에 자연스럽게 녹여주는 통합 라이브러리다.
Passport의 Strategy 패턴
Passport의 핵심 아이디어는 간단하다. 모든 인증 방식은 결국 같은 흐름을 따른다.
요청 → 자격 증명 추출 → 검증 → 사용자 정보 반환 (또는 실패)
JWT든 OAuth든 세션이든, 이 흐름 자체는 동일하다. 다른 건 각 단계의 구현이다.
| 인증 방식 | 자격 증명 추출 | 검증 | 사용자 정보 |
|---|---|---|---|
| JWT | Authorization 헤더에서 Bearer 토큰 | 비밀키로 서명 검증 | 토큰 페이로드 |
| JWT (쿠키) | 쿠키에서 refresh_token | 비밀키로 서명 검증 | 토큰 페이로드 |
| Local (ID/PW) | body에서 username, password | DB 조회 + 비밀번호 비교 | User 엔티티 |
| Google OAuth | 콜백 URL의 authorization code | Google API로 토큰 교환 | Google 프로필 |
Passport는 이 흐름을 추상화하고, 각 인증 방식의 구현을 Strategy라는 플러그인으로 분리한다. passport-jwt, passport-local, passport-google-oauth20 같은 npm 패키지가 각각 하나의 Strategy 구현체다.
Strategy의 구조
모든 Passport Strategy는 두 가지를 정의한다.
- 옵션(Options): 자격 증명을 어디서 어떻게 추출할지
- 검증 함수(verify callback): 추출된 자격 증명으로 사용자를 어떻게 확인할지
Strategy = Options(자격 증명 추출 방법) + Verify(검증 로직)
이 분리 덕분에 JWT Strategy를 만들 때 "토큰을 헤더에서 가져와라, 비밀키는 이거다"는 옵션으로, "이 페이로드가 유효한 사용자인지 확인해라"는 검증 함수로 나눌 수 있다.
@nestjs/passport의 역할
순수 Passport를 NestJS에서 쓰려면 미들웨어로 passport.authenticate('jwt')를 호출해야 한다. 이건 NestJS의 Guard 체계와 맞지 않는다. @nestjs/passport는 이 간극을 메워주는 어댑터 역할을 한다.
요청 → 자격 증명 추출 → 검증 → 사용자 정보 반환 (또는 실패)
@nestjs/passport가 제공하는 핵심 두 가지:
PassportStrategy(Strategy, name)— Passport Strategy를 NestJS Injectable 클래스로 변환AuthGuard(name)— Passport의authenticate()를 NestJS Guard로 변환
passport-jwt의 Strategy → PassportStrategy() → NestJS Injectable
passport.authenticate() → AuthGuard() → NestJS Guard
이 변환 덕분에 Strategy 클래스에서 NestJS의 DI를 자유롭게 사용할 수 있다. ConfigService로 환경변수를 읽거나, UserService로 DB를 조회하거나, RedisService로 블랙리스트를 확인하는 게 전부 가능해진다.
JWT Strategy 구현
가장 흔한 패턴인 JWT 인증을 구현해보자.
PassportStrategy 클래스
Strategy = Options(자격 증명 추출 방법) + Verify(검증 로직)
PassportStrategy(Strategy, 'jwt')에서 첫 번째 인자는 passport-jwt의 Strategy 클래스, 두 번째 인자 'jwt'는 이 전략의 이름이다. 나중에 AuthGuard('jwt')로 이 이름을 참조한다.
super() 옵션
super()에 전달하는 객체가 passport-jwt의 옵션이다. 이 옵션이 "자격 증명을 어디서 어떻게 추출할지"를 결정한다.
jwtFromRequest — 토큰 추출 방법을 지정한다. ExtractJwt가 여러 유틸리티를 제공한다.
npm install @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
fromExtractors에 배열로 여러 추출기를 넣으면, 첫 번째에서 못 찾으면 두 번째를 시도하는 식으로 동작한다. "헤더에 있으면 헤더에서, 없으면 쿠키에서"처럼 유연하게 설정할 수 있다.
secretOrKey — JWT 서명을 검증할 비밀키. 비대칭 키를 쓴다면 secretOrKeyProvider로 동적으로 공개키를 가져올 수도 있다.
ignoreExpiration — false(기본값)면 만료된 토큰을 자동으로 거부한다. true로 설정할 일은 거의 없다.
validate 메서드
validate()는 Passport의 verify callback에 해당한다. passport-jwt가 토큰을 추출하고 서명을 검증한 후, 디코딩된 페이로드를 이 메서드에 넘겨준다.
passport-jwt의 Strategy → PassportStrategy() → NestJS Injectable
passport.authenticate() → AuthGuard() → NestJS Guard
validate()가 반환하는 값이 request.user에 자동으로 부착된다. 단순히 페이로드를 그대로 반환할 수도 있고, DB에서 최신 사용자 정보를 조회해서 반환할 수도 있다.
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
interface JwtPayload {
id: number;
email: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('JWT_ACCESS_SECRET'),
ignoreExpiration: false,
});
}
async validate(payload: JwtPayload) {
return payload;
}
}
DB 조회를 하면 매 요청마다 쿼리가 발생하지만, "토큰 발급 후 사용자가 삭제/차단된 경우"를 잡아낼 수 있다. 반면 페이로드만 반환하면 DB 부하는 없지만 토큰 발급 시점의 정보만 사용하게 된다. 트레이드오프를 상황에 맞게 선택하면 된다.
토큰 블랙리스트 검증
JWT는 stateless하다. 한 번 발급하면 만료될 때까지 유효하다. 그런데 사용자가 로그아웃했거나, 비밀번호를 변경해서 기존 토큰을 무효화해야 한다면? Redis에 블랙리스트를 두고 validate()에서 확인하는 패턴이 일반적이다.
// Authorization: Bearer <token> 헤더에서 추출
ExtractJwt.fromAuthHeaderAsBearerToken()
// 쿠키에서 추출 (커스텀 extractor)
ExtractJwt.fromExtractors([
(req: Request) => req.cookies['access_token']
])
// 쿼리 파라미터에서 추출
ExtractJwt.fromUrlQueryParameter('token')
// 여러 위치에서 순서대로 시도
ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req: Request) => req.cookies['access_token'],
])
passReqToCallback: true 옵션이 핵심이다. 이 옵션을 켜면 validate()의 첫 번째 인자로 Express Request 객체가 전달된다. 디코딩된 페이로드에는 원본 토큰 문자열이 없기 때문에, 블랙리스트 확인을 위해 원본 토큰에 접근하려면 이 옵션이 필요하다.
로그아웃 시 토큰을 블랙리스트에 추가하고, TTL을 토큰의 남은 만료 시간과 동일하게 설정하면 만료된 토큰은 자연스럽게 Redis에서도 제거된다.
Refresh Token Strategy
Access Token은 짧은 만료 시간(보통 15분1시간)을 가지고, Refresh Token은 긴 만료 시간(7일30일)으로 새로운 Access Token을 발급받는 데 사용된다. 두 토큰을 각각 다른 Strategy로 구현한다.
async validate(payload: JwtPayload) {
// payload는 이미 서명 검증이 완료된 상태
// 여기서 추가 검증을 할 수 있다
return payload;
}
Access Token Strategy와의 차이점:
| 설정 | Access Token | Refresh Token |
|---|---|---|
| Strategy 이름 | 'jwt' | 'jwt-refresh' |
| 토큰 추출 | 헤더 Authorization: Bearer | 쿠키 refresh_token |
| 비밀키 | JWT_ACCESS_SECRET | JWT_REFRESH_SECRET |
| 용도 | API 인증 | 토큰 갱신 |
리프레시 토큰을 쿠키에 저장하는 이유는 XSS 공격으로부터 보호하기 위해서다. httpOnly 쿠키는 JavaScript로 접근할 수 없기 때문에, 스크립트 삽입 공격이 성공하더라도 리프레시 토큰은 탈취할 수 없다.
두 Strategy를 동시에 사용하면 이렇게 된다:
async validate(payload: JwtPayload) {
const user = await this.usersService.findById(payload.id);
if (!user) {
throw new UnauthorizedException('존재하지 않는 사용자입니다');
}
if (user.isBanned) {
throw new UnauthorizedException('차단된 사용자입니다');
}
return user;
}
AuthGuard
AuthGuard('jwt')는 @nestjs/passport가 제공하는 팩토리 함수로, Passport의 authenticate() 호출을 NestJS Guard로 감싸준다.
동작 흐름
AuthGuard('jwt') 호출
→ passport.authenticate('jwt') 실행
→ 'jwt'라는 이름으로 등록된 JwtStrategy 찾기
→ Strategy의 옵션대로 토큰 추출
→ 토큰 서명 검증 (passport-jwt 내부)
→ validate() 호출
→ 반환값을 request.user에 부착
→ canActivate() → true 반환
이 과정에서 토큰이 없거나, 서명이 잘못됐거나, validate()에서 예외를 던지면 canActivate()가 실패하고 요청은 거부된다.
AuthGuard 커스터마이즈
기본 AuthGuard의 에러 처리를 변경하고 싶다면 상속해서 handleRequest()를 오버라이드한다.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly redisService: RedisService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('JWT_ACCESS_SECRET'),
passReqToCallback: true, // validate()에 request 객체 전달
});
}
async validate(req: Request, payload: JwtPayload) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) {
const isBlacklisted = await this.redisService.get(
`user:blacklist:jwt:${token}`
);
if (isBlacklisted) {
throw new UnauthorizedException('만료된 토큰입니다');
}
}
return payload;
}
}
handleRequest()는 Strategy의 validate()가 반환한 값(user)과 발생한 에러(err)를 받는다. 기본 구현은 에러가 있으면 그대로 던지지만, 커스터마이즈하면 에러 메시지를 통일하거나 로깅을 추가할 수 있다.
Optional Guard (인증 선택적)
어떤 엔드포인트는 "로그인한 사용자면 사용자 정보를 사용하고, 아니면 비로그인 상태로 처리"해야 할 때가 있다. 예를 들어 게시글 목록에서 로그인한 사용자에게는 북마크 여부를 표시하고, 비로그인 사용자에게는 그냥 목록만 보여주는 경우다.
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => req.cookies['refresh_token'],
]),
secretOrKey: configService.get('JWT_REFRESH_SECRET'),
passReqToCallback: true,
});
}
async validate(req: Request, payload: JwtPayload) {
const refreshToken = req.cookies['refresh_token'];
if (!refreshToken) {
throw new UnauthorizedException('리프레시 토큰이 없습니다');
}
return payload;
}
}
기본 AuthGuard는 인증 실패 시 UnauthorizedException을 던지지만, OptionalJwtGuard는 단순히 null을 반환한다. 이렇게 하면 request.user가 null일 수 있고, 핸들러에서 이를 확인해서 분기 처리한다.
@Controller('auth')
export class AuthController {
@UseGuards(AuthGuard('jwt'))
@Get('profile')
getProfile(@Req() req) {
// Access Token으로 인증된 사용자 정보
return req.user;
}
@UseGuards(AuthGuard('jwt-refresh'))
@Post('refresh')
refreshTokens(@Req() req) {
// Refresh Token으로 인증, 새 토큰 쌍 발급
return this.authService.refreshTokens(req.user);
}
}
모듈 구성
Strategy와 Guard를 NestJS 모듈로 조직하는 방법이다.
PassportModule 등록
AuthGuard('jwt') 호출
→ passport.authenticate('jwt') 실행
→ 'jwt'라는 이름으로 등록된 JwtStrategy 찾기
→ Strategy의 옵션대로 토큰 추출
→ 토큰 서명 검증 (passport-jwt 내부)
→ validate() 호출
→ 반환값을 request.user에 부착
→ canActivate() → true 반환
PassportModule.register()에서 defaultStrategy를 설정하면, AuthGuard()를 인자 없이 호출했을 때 이 기본 Strategy가 사용된다. 여러 Strategy를 쓰는 프로젝트에서는 명시적으로 AuthGuard('jwt'), AuthGuard('jwt-refresh')처럼 이름을 지정하는 게 더 명확하다.
Strategy 등록의 원리
@Injectable() 데코레이터가 붙은 Strategy 클래스를 모듈의 providers에 넣으면, NestJS가 이 클래스를 인스턴스화할 때 PassportStrategy의 생성자가 Passport에 Strategy를 등록한다. 별도의 등록 코드가 필요 없다.
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
return super.canActivate(context);
}
handleRequest(err: any, user: any) {
if (err || !user) {
throw new UnauthorizedException('인증되지 않은 요청입니다.');
}
return user;
}
}
여러 Strategy 조합
실무 프로젝트에서는 보통 여러 인증 방식이 공존한다. 사용자는 JWT로, 관리자는 세션으로, 외부 연동은 API 키로 인증하는 식이다.
@Injectable()
export class OptionalJwtGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any) {
// 에러가 있거나 user가 없어도 예외를 던지지 않음
// user가 있으면 반환, 없으면 null
return user || null;
}
}
각 Strategy는 독립적이다. 'jwt'와 'jwt-refresh'는 서로 다른 비밀키, 다른 토큰 추출 방법을 가진다. 하나를 수정해도 다른 Strategy에 영향이 없다. 새로운 인증 방식이 필요하면 Strategy 클래스 하나만 추가하면 된다.
passport-jwt 외의 Strategy
Passport 생태계에는 500개 이상의 Strategy가 있다. @nestjs/passport의 PassportStrategy()로 어떤 Passport Strategy든 NestJS에 통합할 수 있다.
Local Strategy (ID/비밀번호)
@UseGuards(OptionalJwtGuard)
@Get('posts')
getPosts(@Req() req) {
const userId = req.user?.id ?? null;
return this.postsService.findAll(userId);
// userId가 있으면 북마크 정보 포함, 없으면 기본 목록
}
passport-local은 request body에서 username과 password를 추출한다. validate()의 인자로 추출된 값이 전달되며, 여기서 DB 조회와 비밀번호 비교를 수행한다.
Google OAuth Strategy
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: '1h' },
}),
inject: [ConfigService],
}),
],
providers: [JwtStrategy, JwtRefreshStrategy, AuthService],
exports: [JwtModule],
})
export class AuthModule {}
OAuth Strategy는 validate()에 accessToken, refreshToken, profile이 전달된다. 이 정보로 로컬 DB에서 사용자를 찾거나 새로 생성하면 된다.
패턴이 동일하다는 걸 알 수 있다: super()에 옵션 → validate()에 검증 로직. Strategy가 달라져도 구조는 같다.
Passport vs 직접 구현
Guard에서 직접 JWT를 검증하는 것과 Passport를 쓰는 것의 차이를 정리하면:
| 관점 | 직접 구현 | Passport |
|---|---|---|
| 단순한 JWT만 쓸 때 | 코드 적고 직관적 | 오버엔지니어링일 수 있음 |
| 인증 방식 2개 이상 | 각 Guard마다 중복 코드 | Strategy만 추가 |
| OAuth 통합 | 직접 구현 복잡 | passport-google 등 검증된 라이브러리 |
| 테스트 | Guard 전체를 모킹 | Strategy의 validate()만 테스트 |
| 팀 프로젝트 | 구현 방식 제각각 | Strategy 패턴으로 통일 |
JWT 하나만 쓰는 소규모 프로젝트라면 직접 구현이 더 간단할 수 있다. 하지만 인증 방식이 늘어날 가능성이 있거나, OAuth를 도입할 계획이 있다면 Passport를 쓰는 게 장기적으로 유리하다. 특히 passport-jwt, passport-google-oauth20 같은 라이브러리는 수년간 검증된 구현이라 보안 관점에서도 직접 구현보다 안전하다.
정리
@nestjs/passport는 Passport의 Strategy 기반 인증 체계를 NestJS의 Guard/DI에 통합하는 어댑터다.
PassportStrategy(Strategy, name)— Passport Strategy를 NestJS Injectable로 변환AuthGuard(name)— 해당 Strategy를 사용하는 Guard 생성super()옵션 — 자격 증명 추출 방법 설정 (헤더, 쿠키, 쿼리 등)validate()메서드 — 검증 로직 구현, 반환값이request.user에 부착passReqToCallback: true— validate()에서 원본 Request 접근 가능handleRequest()오버라이드 — 에러 처리 커스터마이즈 (Optional Guard 등)
Strategy 패턴의 장점은 확장성이다. 새로운 인증 방식이 필요하면 Strategy 클래스 하나를 추가하고, AuthGuard('이름')으로 참조하면 끝이다. 기존 코드를 건드릴 필요가 없다.