icon

JWT 기반 인증 시스템 구축


 JWT(JSON Web Token)는 당사자 간 정보를 안전하게 JSON 객체로 전송하기 위한 컴팩트하고 독립적인 방식을 정의하는 개방형 표준(RFC 7519)입니다.

JWT의 구조와 작동 원리

 JWT는 세 부분으로 구성됩니다. 헤더, 페이로드, 서명

  • 헤더 : 토큰 유형과 사용된 해시 알고리즘 정보
  • 페이로드 : 클레임(사용자 ID 등) 정보
  • 서명 : 토큰의 유효성을 검증하는 데 사용

 각 부분은 Base64Url로 인코딩되어 .으로 구분됩니다.

 JWT vs 세션 기반 인증

 장점

  1. 서버 측 상태 저장 불필요 (무상태성)
  2. 확장성이 좋음 (서버 간 세션 공유 불필요)
  3. 모바일 애플리케이션에 적합

 단점

  1. 토큰 크기가 세션 ID보다 큼
  2. 토큰 무효화가 어려움
  3. 중요 정보 저장 시 보안 위험

NestJS에 JWT 인증 구현

  1. 의존성 설치
npm install @nestjs/jwt passport-jwt @types/passport-jwt
  1. JWT 모듈 설정 (app.module.ts)
import { JwtModule } from '@nestjs/jwt';
 
@Module({
  imports: [
    JwtModule.register({
      secret: 'YOUR_SECRET_KEY',
      signOptions: { expiresIn: '60m' },
    }),
  ],
  // ...
})
export class AppModule {}
  1. AuthService 구현
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
 
@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}
 
  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}
  1. JWT 전략 구현
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
 
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'YOUR_SECRET_KEY',
    });
  }
 
  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

JWT 토큰 생성, 검증, 갱신

  1. 토큰 생성
@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}
 
  createToken(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return this.jwtService.sign(payload);
  }
}
  1. 토큰 검증
@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}
 
  validateToken(token: string) {
    try {
      const payload = this.jwtService.verify(token);
      return payload;
    } catch (error) {
      throw new UnauthorizedException('Invalid token');
    }
  }
}
  1. 토큰 갱신
@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}
 
  refreshToken(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return this.jwtService.sign(payload, { expiresIn: '7d' });
  }
}

JWT 가드 생성 및 적용

  1. JWT 가드 생성
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
 
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
  1. 특정 라우트에 적용
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
  return req.user;
}
  1. 전역 가드로 설정 (main.ts)
import { JwtAuthGuard } from './auth/jwt-auth.guard';
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new JwtAuthGuard());
  await app.listen(3000);
}
bootstrap();

리프레시 토큰 메커니즘

  1. 리프레시 토큰 생성
@Injectable()
export class AuthService {
  createTokens(user: any) {
    const accessToken = this.jwtService.sign({ sub: user.id }, { expiresIn: '15m' });
    const refreshToken = this.jwtService.sign({ sub: user.id }, { expiresIn: '7d' });
    return { accessToken, refreshToken };
  }
}
  1. 리프레시 토큰 검증 및 새 액세스 토큰 발급
@Injectable()
export class AuthService {
  async refreshAccessToken(refreshToken: string) {
    try {
      const payload = this.jwtService.verify(refreshToken);
      const user = await this.usersService.findById(payload.sub);
      return this.jwtService.sign({ sub: user.id }, { expiresIn: '15m' });
    } catch (error) {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }
}

토큰 블랙리스팅/화이트리스팅

 Redis를 사용한 토큰 블랙리스팅 예시

import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
 
@Injectable()
export class TokenBlacklistService {
  private redisClient: Redis;
 
  constructor() {
    this.redisClient = new Redis();
  }
 
  async blacklistToken(token: string, exp: number) {
    const ttl = exp * 1000 - Date.now();
    await this.redisClient.set(token, 'blacklisted', 'PX', ttl);
  }
 
  async isBlacklisted(token: string): Promise<boolean> {
    const result = await this.redisClient.get(token);
    return result === 'blacklisted';
  }
}

JWT 관련 보안 위협 대처

 1. XSS 대응

  • HttpOnly 쿠키 사용
  • 컨텐츠 보안 정책(CSP) 설정

 2. CSRF 대응

  • CSRF 토큰 사용
  • SameSite 쿠키 속성 설정

 3. JWT 보안 설정

JwtModule.register({
  secret: process.env.JWT_SECRET,
  signOptions: { 
    expiresIn: '15m',
    audience: 'https://yourdomain.com',
    issuer: 'https://yourdomain.com',
  },
}),

Best Practices와 성능 최적화

  1. 환경변수 사용 : JWT 비밀키를 환경변수로 관리
  2. 토큰 만료 시간 최소화 : 액세스 토큰의 수명을 짧게 유지 (15분-1시간)
  3. 비대칭 키 사용 고려 : RS256 알고리즘 사용
  4. 토큰 페이로드 최소화 : 필요한 정보만 포함
  5. 로깅 및 모니터링 : 비정상적인 토큰 사용 패턴 감지
  6. 정기적인 키 순환 : JWT 서명 키를 주기적으로 변경
  7. Rate Limiting 구현 : 토큰 생성 및 갱신 요청에 대한 제한 설정
  8. 에러 메시지 일반화 : 구체적인 에러 정보 노출 방지
  9. 토큰 저장소 사용 : 중요한 작업에 대해 서버 측 토큰 검증
  10. 성능 최적화
  • 캐싱 사용 : 자주 사용되는 토큰 정보 캐싱
  • 비동기 처리 : 토큰 검증 로직을 비동기로 구현

 NestJS에서 JWT 기반 인증 시스템을 구축할 때는 보안과 성능의 균형을 잘 맞추는 것이 중요합니다.

 JWT의 무상태성을 활용하여 확장성 있는 시스템을 구축할 수 있지만 동시에 토큰의 안전한 관리와 사용에 주의를 기울여야 합니다.

 리프레시 토큰 메커니즘을 통해 액세스 토큰의 수명을 짧게 유지하면서도 사용자 경험을 해치지 않는 인증 시스템을 구현할 수 있습니다.

 또한 토큰 블랙리스팅이나 화이트리스팅을 통해 필요 시 토큰을 무효화할 수 있는 유연성을 확보할 수 있습니다.