본문으로 건너뛰기

안동민 개발노트

본문 시작

JWT 기반 인증 시스템 구축

JWT의 헤더·페이로드·서명을 이해하고 토큰 발급, Bearer 검증, 보호된 경로 접근까지 연결합니다.

지난 절에서는 Passport.js를 활용해 기본적인 로컬 인증 시스템을 구축하는 방법을 알아보았습니다.

이번 절에서는 JWT(JSON Web Token) access token을 NestJS에 통합해 로그인 결과를 발급하고 보호된 요청에서 검증하는 방법을 다룹니다.

JWT는 JSON 클레임을 간결하고 URL-safe한 문자열로 표현하는 형식입니다. 이 절에서 사용하는 서명된 JWT는 토큰의 무결성과 신뢰하는 키로 만들어졌는지를 확인할 수 있게 하지만, 페이로드를 암호화하지는 않습니다.

서버는 세션 조회 없이 access token의 서명과 클레임을 검증하는 무상태 경로를 구성할 수 있습니다. 다만 로그아웃 전 즉시 폐기, 정지된 계정 확인, 최신 역할 반영, refresh token 회전·재사용 탐지가 필요하면 별도의 서버 상태나 조회 경계가 다시 필요합니다.


JWT(JSON Web Tokens)란 무엇인가?

이 절에서 사용하는 서명된 compact JWT는 세 부분으로 구성된 문자열입니다.

각 부분은 .으로 구분됩니다.

header.payload.signature

Header (헤더): 토큰의 타입(JWT)과 서명에 사용된 알고리즘(예: HS256, RS256)이 포함됩니다.

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload (페이로드): 클레임(Claim)이라고 불리는 실제 정보가 포함됩니다.

페이로드에는 sub, exp 같은 등록된 클레임과 애플리케이션 전용 클레임을 넣을 수 있습니다. 요청 처리에 필요한 최소 값만 선택해야 합니다.

페이로드 정보는 암호화되지 않고 Base64Url로 인코딩만 되므로, 민감한 정보를 직접 넣어서는 안 됩니다.

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622,
  "roles": ["admin", "user"]
}

sub는 사용자 ID 같은 고유 식별자이고, iat는 발급 시간, exp는 만료 시간을 뜻합니다.

Signature (서명): Base64Url로 인코딩한 헤더와 페이로드를 입력으로 사용해 만든 암호학적 검증 값입니다. HS256은 발급자와 검증자가 같은 비밀 키를 사용하고, RS256 같은 비대칭 알고리즘은 개인 키로 서명하고 공개 키로 검증합니다.

서명은 토큰이 서명 이후 변경되지 않았고 신뢰하는 키로 발급되었는지 확인하는 데 사용됩니다. 서명 자체는 페이로드를 숨기지 않습니다.

클라이언트가 토큰을 서버에 전송하면, 서버는 이 서명을 검증하여 토큰의 무결성을 확인합니다.

JWT는 Base64Url로 인코딩된 문자열이기 때문에 디코딩하면 내용을 쉽게 볼 수 있습니다.

따라서 페이로드에 민감한 정보를 절대 직접 넣어서는 안 됩니다.

민감한 정보는 서버에 저장하고, JWT에는 요청 처리에 필요한 최소 식별자와 클레임만 포함해야 합니다. Bearer token을 가진 주체가 그대로 권한을 행사하므로 전송 구간도 HTTPS로 보호해야 합니다.


NestJS에서 JWT 기반 인증 시스템 구축하기

JWT 인증 시스템은 토큰 발급, payload 검증, guard 연결, 만료/갱신 기준으로 읽습니다.

Nest JWT 인증에서 로컬 인증 성공 뒤 access token을 발급하고, 이후 별도 보호 요청에서 Bearer token의 허용 알고리즘, 서명, 만료와 애플리케이션 claim 형태를 차례로 검증한 다음 req.user를 만들거나 401로 중단하는 흐름

Nest · JWT Access Flow

발급과 검증은 서로 다른 요청 경계다

로그인 요청은 자격 증명을 검증한 뒤 토큰을 발급합니다. 보호 요청에서는 Passport JWT의 서명·만료 검증과 애플리케이션의 claim 형태 검증을 모두 통과해야 현재 요청의 사용자가 됩니다.

Nest JWT access token 발급과 검증 흐름도 로그인 요청에서 LocalAuthGuard가 local 전략으로 자격 증명을 검증하고 req.user를 만든다. AuthService는 JwtService로 최소 payload와 만료 시간을 가진 HS256 access token에 서명한다. 이후 별도 프로필 요청에서 JwtAuthGuard가 Bearer token을 추출하고 Passport JWT가 허용 알고리즘, 서명, 만료를 검증한다. 전략의 validate가 sub, username, roles의 런타임 형태를 확인한 뒤 반환값을 req.user로 넣고 핸들러를 실행하며, 어느 검증이든 실패하면 401로 종료한다. ISSUE · POST /auth/login VERIFY · GET /auth/profile YES INVALID POST /auth/login username · password LocalAuthGuard local strategy · req.user AuthService → JwtService sign(payload) · HS256 · exp access_token signed · not encrypted GET /auth/profile Authorization: Bearer <token> JwtAuthGuard → JwtStrategy extract · verify 서명·alg·exp·claim 형태 모두 유효한가? req.user → handler 모든 검증 통과 401 · stop handler not run

1. access token 발급

  1. LocalAuthGuard가 자격 증명을 검증하고 성공 사용자를 req.user에 넣습니다.
  2. 컨트롤러가 AuthService.login()을 호출하고, JwtService.sign()이 최소 payload와 exp를 포함한 HS256 token을 만듭니다.
  3. access_token은 서명되지만 암호화되지 않으므로 민감 정보를 담지 않습니다.

2. 보호 요청 검증

  1. JwtAuthGuard가 Passport JWT를 실행하고 Authorization 헤더에서 Bearer token을 추출합니다.
  2. 허용 알고리즘, 서명, 만료 검증을 통과한 token만 전략의 validate()로 전달됩니다.
  3. validate()sub, username, roles의 런타임 형태를 검사합니다. TypeScript 타입만으로는 외부 claim을 검증할 수 없습니다.
  4. 성공하면 반환값이 req.user가 되어 핸들러가 실행됩니다. 어느 검증이든 실패하면 401에서 중단됩니다.

이 흐름은 authentication 경계입니다. req.user가 만들어져도 역할·소유권에 따른 authorization은 별도 가드나 정책에서 판정해야 합니다.

JWT 기반 인증 시스템은 주로 다음과 같은 흐름으로 동작합니다.

사용자 로그인: 클라이언트가 아이디/비밀번호를 서버에 전송합니다.

인증 및 토큰 발급: 서버는 사용자 정보를 검증하고, 유효하면 해당 사용자에 대한 JWT를 생성하여 클라이언트에 응답으로 보냅니다.

리소스 접근: 클라이언트는 서버에 보호된 리소스에 접근할 때마다 HTTP Authorization 헤더에 Bearer 스키마와 함께 JWT를 포함하여 전송합니다.

토큰 검증 및 요청 사용자 생성: Passport JWT는 Bearer token 추출, 허용 알고리즘, 서명, 만료 시간을 검증합니다. 전략의 validate()는 애플리케이션이 기대하는 claim 형태와 필요한 사용자 상태를 검증하고, 반환값을 현재 요청의 req.user에 넣습니다. 역할 같은 클레임을 실제 접근 허용으로 바꾸는 권한 부여는 별도 가드가 맡습니다.

이 절의 예제는 수명이 짧은 access token만 구현합니다. 로그인 상태를 더 오래 이어가야 한다면 refresh token을 추가할 수 있지만, 이는 JWT가 자동으로 제공하는 기능이 아닙니다. 발급 여부부터 위험 기준으로 결정하고 전송·저장 기밀성, client·grant 결속, scope·resource 제한, 만료와 폐기 경계를 함께 설계해야 합니다. public client에는 sender-constrained refresh token 또는 rotation 중 하나가 필요합니다. rotation을 선택하면 원문 token 대신 해시나 식별자와 family 관계를 보존하고, 매 사용 시 교체하며 이미 무효화된 token의 재사용을 탐지하면 활성 family를 폐기해 재로그인을 요구합니다. 일정 기간 사용하지 않은 refresh token은 만료시키고, 필요하면 절대 최대 수명과 로그아웃·비밀번호 변경 같은 보안 이벤트의 폐기 기준도 둡니다.

이제 NestJS에서 이 과정을 구현해 보겠습니다.

단계 1: 필요한 패키지 설치
npm install @nestjs/jwt passport-jwt @nestjs/config
npm install --save-dev @types/passport-jwt
단계 2: AuthModuleAuthService 업데이트

기존 AuthModuleAuthService에 JWT 관련 기능을 추가합니다.

src/auth/auth.module.ts (업데이트)
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { ConfigService } from '@nestjs/config';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.registerAsync({
      useFactory: (configService: ConfigService) => ({
        secret: configService.getOrThrow<string>('JWT_SECRET'),
        signOptions: { algorithm: 'HS256', expiresIn: '60s' },
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

위 코드는 3장에서 ConfigModule.forRoot({ isGlobal: true })를 Root 모듈에 등록했다고 가정합니다. 아직 설정하지 않았다면 애플리케이션 시작 지점에서 먼저 등록해야 .env와 프로세스 환경 변수를 일관되게 읽을 수 있습니다.

  • getOrThrow('JWT_SECRET'): 필수 키가 없을 때 애플리케이션 시작을 실패시켜 undefined 비밀 키로 실행되는 구성을 막습니다.
  • algorithm: 이 예제의 발급 알고리즘을 HS256으로 고정합니다. 검증 전략에도 같은 허용 목록을 선언합니다.
  • expiresIn: 60s는 만료 동작을 빠르게 확인하기 위한 실습 값입니다. 운영 값은 위험과 사용자 흐름에 맞춰 별도로 정합니다.
src/auth/auth.service.ts (업데이트)
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt'; // JwtService 임포트

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    const payload = {
      sub: user.userId,
      username: user.username,
      roles: user.roles,
    };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

jwtService.sign(payload)JwtModule의 비밀 키, 알고리즘, 만료 설정으로 페이로드에 서명합니다. 페이로드를 암호화하지는 않습니다.

단계 3: JWT 전략 구현 (JwtStrategy)

클라이언트가 전송한 JWT를 검증하고, 유효한 토큰에서 사용자 정보를 추출하는 전략을 만듭니다.

src/auth/strategies/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

type JwtPayload = {
  sub: number;
  username: string;
  roles: string[];
};

function isJwtPayload(value: unknown): value is JwtPayload {
  if (typeof value !== 'object' || value === null) return false;

  const payload = value as Record<string, unknown>;
  return (
    typeof payload.sub === 'number' &&
    Number.isSafeInteger(payload.sub) &&
    typeof payload.username === 'string' &&
    Array.isArray(payload.roles) &&
    payload.roles.every((role) => typeof role === 'string')
  );
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
      algorithms: ['HS256'],
      ignoreExpiration: false,
    });
  }

  validate(payload: unknown) {
    if (!isJwtPayload(payload)) {
      throw new UnauthorizedException('Invalid JWT claims');
    }

    return {
      userId: payload.sub,
      username: payload.username,
      roles: payload.roles,
    };
  }
}
  • jwtFromRequest: Authorization: Bearer <token> 헤더에서 JWT를 추출합니다.
  • secretOrKeyalgorithms: 발급에 사용한 HS256 비밀 키와 허용 알고리즘을 함께 고정합니다.
  • ignoreExpiration: false이면 exp가 지난 토큰을 거부합니다.

Passport JWT는 토큰 추출과 허용 알고리즘·서명·만료 검증에 성공한 뒤에만 validate()를 호출합니다. 그러나 JwtPayload 같은 TypeScript 타입은 컴파일 뒤 사라지므로 디코딩된 JSON을 검증하지 않습니다. 위 type guard는 sub, username, roles의 런타임 형태를 확인하고 잘못된 claim을 401로 거부합니다. 검사를 통과해 validate()가 반환한 객체가 req.user가 됩니다. 이 예제처럼 페이로드만 매핑하면 빠른 무상태 경로가 되지만, 정지된 계정이나 최신 역할을 매 요청에 반영해야 한다면 payload.sub로 사용자 저장소를 조회하고 실패를 거부해야 합니다.

단계 4: 환경 변수 설정

.env 파일에 JWT 비밀 키를 추가합니다. (프로젝트 루트 디렉토리에 .env 파일이 없다면 생성)

# .env
JWT_SECRET=replace-with-a-random-development-secret

위 값은 형식만 보여 주는 placeholder입니다. 운영 환경에서는 암호학적으로 안전한 난수 생성기로 충분한 엔트로피의 키를 만들고 비밀 관리 시스템에서 주입해야 합니다. 실제 .env와 비밀 키는 Git 저장소에 커밋하지 않습니다.

단계 5: 인증 컨트롤러(AuthController) 업데이트

전략 이름을 라우트마다 반복하지 않도록 JWT 가드를 먼저 정의합니다.

src/auth/guards/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

login 엔드포인트는 JWT를 반환하도록 수정하고, 보호된 프로필 라우트에는 JwtAuthGuard를 적용합니다.

src/auth/auth.controller.ts (업데이트)
import {
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Post,
  Request,
  UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('login')
  @HttpCode(HttpStatus.OK)
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

JWT 인증 시스템 테스트해보기

애플리케이션을 실행합니다.

npm run start:dev

로그인 요청 (JWT 발급): Postman 등을 사용하여 http://localhost:3000/auth/login으로 POST 요청을 보냅니다.

Body (JSON)

{
    "username": "testuser",
    "password": "password123"
}

성공적으로 로그인하면 다음과 유사한 JWT를 응답으로 받게 됩니다.

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyIiwic3ViIjoxLCJyb2xlcyI6WyJ1c2VyIl0sImlhdCI6MTY3ODg4NjQwMCwiZXhwIjoxNjc4ODg2NDYwfQ.YOUR_JWT_TOKEN_HERE"
}

access_token 값을 복사해 둡니다.

보호된 리소스 접근 (JWT 사용): 새로운 요청을 생성하여 http://localhost:3000/auth/profileGET 요청을 보냅니다.

Headers 탭에서 다음과 같이 Authorization 헤더를 추가합니다.

  • Key: Authorization
  • Value: Bearer <복사한 access_token 값> (예: Bearer eyJhbGciOiJIUzI1Ni...)

성공적으로 접근하면 다음과 유사한 사용자 정보가 포함된 응답을 받게 됩니다.

{
    "userId": 1,
    "username": "testuser",
    "roles": ["user"]
}

iatexp는 서명된 토큰 페이로드에는 들어 있지만, 이 예제의 JwtStrategy.validate() 반환값에는 포함하지 않았으므로 req.user 응답에도 나타나지 않습니다.

토큰 만료 확인: JwtModule에 설정한 expiresIn 60초가 지난 후 다시 profile 엔드포인트에 요청을 보내면 라우트 핸들러가 실행되지 않고 401 Unauthorized 응답을 받습니다. 토큰 누락, 잘못된 서명, 허용되지 않은 알고리즘도 같은 인증 실패 경계에서 거부됩니다.


JWT 인증은 토큰 발급 자체보다 검증 실패 경계, 권한 부여와의 분리, 만료와 폐기 정책을 함께 설계할 때 안정적인 인증 흐름이 됩니다.

Nest JWT access token의 발급, 전달, 검증, 사용자와 권한, 수명과 폐기 경계에서 이 예제가 구현하는 동작과 운영 환경에서 별도로 결정해야 할 항목을 비교한 표

Nest · JWT Boundaries

서명이 보장하는 것과 운영 정책을 분리한다

서명 검증은 token의 무결성과 신뢰하는 키로 발급되었는지를 확인합니다. 기밀성, 전송 보호, 최신 계정 상태, 권한 판정, 즉시 폐기는 각각 별도 경계에서 설계합니다.

access token 경계별 예제 동작과 추가 운영 결정
경계 이 예제가 하는 일 운영 환경의 추가 결정
발급 sub, username, rolesexp를 가진 token에 HS256으로 서명합니다. 서명은 암호화가 아닙니다. 비밀 값을 payload에 넣지 않고, 충분한 엔트로피의 키를 비밀 관리 시스템에서 보관·교체합니다.
전달 클라이언트가 보호 요청의 Authorization: Bearer <token> 헤더로 token을 보냅니다. HTTPS를 강제하고, 브라우저 저장·전송 방식에 따라 XSS와 CSRF 위협을 함께 평가합니다.
검증 Passport JWT가 Bearer token을 추출해 허용 알고리즘, 서명, exp를 확인하고, validate()가 앱 claim 형태를 런타임에 검증합니다. TypeScript 타입은 외부 claim을 검증하지 않습니다. sub, username, roles와 필요한 issuer, audience를 실제로 확인합니다.
사용자·권한 validate() 반환값을 req.user로 만들며, 인증된 사용자 컨텍스트까지만 제공합니다. 정지 계정과 최신 역할을 즉시 반영하려면 사용자 저장소를 조회합니다. 역할·소유권 접근은 별도 authorization 가드나 정책으로 판정합니다.
수명·폐기 60s는 만료를 확인하기 위한 access token 실습 값이며, 만료된 token은 401로 거부합니다. refresh token 발급 여부를 위험 기준으로 정하고, public client에는 sender-constrained 방식 또는 rotation을 사용합니다. rotation이면 family 관계와 재사용 탐지 상태를 보존합니다. 유휴 만료와 필요 시 최대 수명, 로그아웃·비밀번호 변경 시 폐기도 정합니다. 추가 상태가 없는 서명 access token은 만료 전 개별 폐기할 수 없습니다.

발급

sub, username, roles, exp를 가진 token에 HS256으로 서명합니다.

서명은 암호화가 아닙니다. 비밀 payload를 피하고 충분한 엔트로피의 키를 비밀 관리 시스템에서 보관·교체합니다.

전달

보호 요청은 Authorization: Bearer <token> 헤더로 token을 보냅니다.

HTTPS를 강제하고 브라우저 저장·전송 방식에 따른 XSS와 CSRF 위협을 함께 평가합니다.

검증

Passport JWT가 추출·허용 알고리즘·서명·exp를 확인하고, validate()가 앱 claim 형태를 런타임에 검증합니다.

TypeScript 타입만 믿지 말고 sub, username, roles와 필요한 issuer, audience를 실제로 확인합니다.

사용자·권한

validate() 반환값이 req.user가 됩니다. 이는 인증된 사용자 컨텍스트이지 모든 접근의 자동 허용이 아닙니다.

정지 계정·최신 역할은 저장소 조회로, 역할·소유권 접근은 별도 authorization 정책으로 판정합니다.

수명·폐기

60s는 실습용 access token 수명입니다. 만료 token은 401로 거부됩니다.

발급 여부를 위험 기준으로 정하고, public client에는 sender-constrained 방식 또는 rotation을 사용합니다. rotation이면 family 관계와 재사용 탐지 상태를 보존합니다. 유휴 만료와 로그아웃·비밀번호 변경 시 폐기도 정합니다.

무상태 검증은 세션 조회를 줄이는 대신 발급된 access token의 즉시 폐기를 어렵게 만듭니다. 필요한 운영 제어를 선택하면 그만큼의 서버 상태와 검증 비용도 함께 받아들여야 합니다.


JWT 인증은 코드상으로 JwtModule, JwtStrategy, JwtAuthGuard를 연결하는 작업이지만, 운영 기준은 payload 최소화, 허용 알고리즘, 만료 시간, 비밀 키 관리와 선택한 폐기·refresh 정책까지 함께 잡아야 안정적입니다.

다음 절에서는 인증된 사용자가 어떤 기능에 접근할 수 있는지 결정하는 권한 부여(Authorization)를 다룹니다.