이미지 로딩 중...

JWT 최신 기능 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 8 Views

JWT 최신 기능 완벽 가이드

JWT의 최신 기능과 보안 강화 방법을 실무 중심으로 알아봅니다. 토큰 암호화, 키 로테이션, 클레임 검증 등 고급 개발자를 위한 JWT 활용법을 다룹니다.


목차

  1. JWT 암호화(JWE) - 민감한 데이터를 안전하게 전송하기
  2. 키 로테이션(Key Rotation) - 보안 침해에 대비하기
  3. 클레임 검증(Claims Validation) - 토큰 남용 방지하기
  4. Refresh Token 패턴 - 장기 세션을 안전하게 유지하기
  5. 토큰 블랙리스트 - 즉시 로그아웃 구현하기
  6. 범위 제한(Scope) - 최소 권한 원칙 적용하기
  7. 토큰 지문 인식(Token Fingerprinting) - XSS 공격 방어하기
  8. 비대칭 키 암호화(RS256) - 마이크로서비스에서 안전하게 검증하기

1. JWT 암호화(JWE) - 민감한 데이터를 안전하게 전송하기

시작하며

여러분이 JWT에 사용자의 이메일, 권한 정보, 심지어 결제 정보를 담아서 전송할 때 이런 고민을 해본 적 있나요? "JWT는 Base64로 인코딩되어 있어서 누구나 디코딩하면 내용을 볼 수 있는데, 이게 안전한가?" 맞습니다.

기본 JWT(JWS)는 서명만 되어 있을 뿐 암호화되지 않습니다. 누구나 jwt.io 같은 사이트에서 페이로드를 확인할 수 있죠.

이는 민감한 정보를 다룰 때 심각한 보안 문제가 될 수 있습니다. 바로 이럴 때 필요한 것이 JWT 암호화(JWE, JSON Web Encryption)입니다.

JWE를 사용하면 토큰의 페이로드를 암호화하여 권한이 있는 사람만 내용을 확인할 수 있게 만들 수 있습니다.

개요

간단히 말해서, JWE는 JWT의 페이로드를 암호화하여 제3자가 토큰 내용을 읽을 수 없도록 보호하는 기술입니다. 실무에서는 특히 마이크로서비스 간 통신이나 프론트엔드와 백엔드 간 민감한 데이터 전송 시 JWE가 필수적입니다.

예를 들어, 결제 시스템에서 사용자의 카드 정보 일부를 JWT에 담아야 한다면, 반드시 JWE를 사용해야 합니다. 기존에는 HTTPS로만 전송 구간을 보호했다면, 이제는 JWE로 토큰 자체를 암호화하여 이중 보안을 구현할 수 있습니다.

HTTPS가 해제된 후에도 토큰 내용은 여전히 암호화된 상태로 유지됩니다. JWE의 핵심 특징은 첫째, 다양한 암호화 알고리즘 지원(RSA-OAEP, AES-GCM 등), 둘째, 키 래핑을 통한 안전한 키 교환, 셋째, 압축 지원으로 토큰 크기 최적화입니다.

이러한 특징들이 엔터프라이즈급 애플리케이션에서 JWT를 안전하게 사용할 수 있게 만들어줍니다.

코드 예제

// jose 라이브러리를 사용한 JWE 생성 및 검증
import { EncryptJWT, jwtDecrypt } from 'jose';

// JWE 토큰 생성 - 민감한 데이터 암호화
async function createEncryptedToken(payload, secret) {
  // 비밀키를 Uint8Array로 변환
  const secretKey = new TextEncoder().encode(secret);

  // JWE 토큰 생성 - A256GCM 알고리즘 사용
  const jwt = await new EncryptJWT(payload)
    .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
    .setIssuedAt()
    .setExpirationTime('2h')
    .encrypt(secretKey);

  return jwt;
}

// JWE 토큰 복호화 및 검증
async function decryptToken(token, secret) {
  const secretKey = new TextEncoder().encode(secret);
  const { payload } = await jwtDecrypt(token, secretKey);
  return payload;
}

// 실사용 예시
const sensitiveData = { email: 'user@example.com', role: 'admin', ssn: '123-45-6789' };
const encryptedToken = await createEncryptedToken(sensitiveData, 'your-256-bit-secret');
const decryptedData = await decryptToken(encryptedToken, 'your-256-bit-secret');

설명

이것이 하는 일: JWE는 JWT의 페이로드를 강력한 암호화 알고리즘으로 보호하여, 토큰을 가진 사람도 올바른 키 없이는 내용을 확인할 수 없게 만듭니다. 첫 번째 단계로, EncryptJWT 클래스를 사용하여 암호화할 페이로드를 설정합니다.

setProtectedHeader에서 alg: 'dir'는 직접 키를 사용한다는 의미이고, enc: 'A256GCM'은 AES-256-GCM 알고리즘으로 암호화한다는 뜻입니다. 이 알고리즘은 현재 가장 안전한 대칭키 암호화 방식 중 하나로, NIST에서 승인한 표준입니다.

두 번째 단계에서, setIssuedAt()setExpirationTime()으로 토큰의 유효 기간을 설정합니다. 그 다음 encrypt() 메서드가 실행되면서 실제 암호화가 일어납니다.

내부적으로는 랜덤 IV(Initialization Vector)를 생성하고, 페이로드를 JSON으로 직렬화한 후, AES-256-GCM으로 암호화합니다. 결과물은 5개 부분으로 구성된 문자열(헤더.암호화된키.IV.암호문.인증태그)입니다.

세 번째 단계로, 복호화 시 jwtDecrypt() 함수가 토큰을 파싱하고 같은 비밀키를 사용하여 복호화합니다. 이 과정에서 자동으로 만료 시간도 검증되므로, 별도의 검증 로직이 필요 없습니다.

최종적으로 원본 페이로드 객체를 얻게 됩니다. 여러분이 이 코드를 사용하면 민감한 사용자 데이터를 안전하게 프론트엔드나 다른 마이크로서비스로 전송할 수 있습니다.

실무에서의 이점은 첫째, 토큰이 로그에 남아도 내용을 알 수 없어 안전하고, 둘째, GDPR 같은 개인정보 보호 규정 준수에 도움이 되며, 셋째, Redis나 데이터베이스에 민감한 정보를 저장하지 않아도 되어 아키텍처가 단순해집니다.

실전 팁

💡 비밀키는 반드시 256비트(32바이트) 이상으로 설정하세요. crypto.randomBytes(32).toString('hex')로 안전한 키를 생성할 수 있습니다. 💡 프로덕션 환경에서는 절대 하드코딩하지 말고 환경변수나 AWS Secrets Manager, HashiCorp Vault 같은 비밀 관리 시스템을 사용하세요. 💡 JWE는 JWS보다 토큰 크기가 크므로(약 30-40% 증가), 쿠키에 저장할 때는 크기 제한(4KB)을 고려해야 합니다. 필요시 헤더에 담거나 로컬 스토리지를 사용하세요. 💡 비대칭키 암호화(RSA-OAEP)가 필요한 경우, generateKeyPair로 공개키/개인키 쌍을 생성하고, 공개키로 암호화하여 개인키는 백엔드에만 보관하세요. 💡 JWE 토큰을 디버깅할 때는 jwt.io가 아닌 코드 내에서 jwtDecrypt를 직접 실행해야 내용을 확인할 수 있습니다. 이는 보안상 장점이기도 합니다.


2. 키 로테이션(Key Rotation) - 보안 침해에 대비하기

시작하며

여러분의 서비스가 수백만 명의 사용자를 보유하고 있다고 가정해봅시다. 그런데 어느 날 개발자 한 명이 실수로 JWT 비밀키를 GitHub에 커밋했다는 사실을 발견했습니다.

이럴 때 어떻게 하시겠습니까? 기존에는 비밀키를 변경하면 모든 사용자가 강제 로그아웃되어 사용자 경험이 크게 저하됩니다.

하지만 키를 변경하지 않으면 공격자가 무제한으로 유효한 토큰을 만들 수 있는 심각한 보안 위험에 노출됩니다. 바로 이럴 때 필요한 것이 키 로테이션입니다.

키 로테이션을 구현하면 여러 개의 키를 동시에 사용하여 점진적으로 키를 전환할 수 있고, 보안 침해 시에도 서비스 중단 없이 대응할 수 있습니다.

개요

간단히 말해서, 키 로테이션은 여러 개의 JWT 서명 키를 관리하고 주기적으로 새 키로 전환하여 보안을 강화하는 메커니즘입니다. 실무에서는 특히 대규모 서비스에서 필수적입니다.

예를 들어, 금융 앱이나 의료 시스템처럼 보안이 중요한 서비스에서는 규정상 90일마다 키를 교체해야 하는 경우가 많습니다. 키 로테이션을 구현하지 않으면 이러한 요구사항을 충족할 수 없습니다.

기존에는 단일 비밀키만 사용했다면, 이제는 여러 키를 동시에 관리하고 kid(Key ID) 헤더를 통해 어떤 키로 서명되었는지 식별할 수 있습니다. 토큰 검증 시에는 kid를 보고 적절한 키를 선택합니다.

키 로테이션의 핵심 특징은 첫째, 무중단 키 전환(새 키로 서명하면서 구 키로 서명된 토큰도 여전히 검증 가능), 둘째, 키별 만료 시간 관리, 셋째, 자동화된 키 교체 프로세스입니다. 이러한 특징들이 엔터프라이즈 환경에서 안전하고 지속 가능한 인증 시스템을 구축할 수 있게 해줍니다.

코드 예제

import { SignJWT, jwtVerify, generateKeyPair } from 'jose';

// 키 저장소 - 실제로는 Redis나 데이터베이스 사용
class KeyStore {
  constructor() {
    this.keys = new Map();
    this.currentKeyId = null;
  }

  // 새로운 키 생성 및 추가
  async addKey(keyId) {
    const { publicKey, privateKey } = await generateKeyPair('RS256');
    this.keys.set(keyId, { publicKey, privateKey, createdAt: Date.now() });
    this.currentKeyId = keyId;
    return keyId;
  }

  // 현재 활성화된 키로 토큰 서명
  async signToken(payload) {
    const { privateKey } = this.keys.get(this.currentKeyId);
    return await new SignJWT(payload)
      .setProtectedHeader({ alg: 'RS256', kid: this.currentKeyId })
      .setExpirationTime('2h')
      .sign(privateKey);
  }

  // kid를 보고 적절한 키로 토큰 검증
  async verifyToken(token) {
    // 먼저 헤더에서 kid 추출
    const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
    const key = this.keys.get(header.kid);

    if (!key) throw new Error('Unknown key ID');

    const { payload } = await jwtVerify(token, key.publicKey);
    return payload;
  }

  // 오래된 키 제거 (만료된 토큰이 없을 때만)
  removeKey(keyId) {
    this.keys.delete(keyId);
  }
}

// 사용 예시
const keyStore = new KeyStore();
await keyStore.addKey('key-2024-01');

// 토큰 발급
const token = await keyStore.signToken({ userId: 123, role: 'admin' });

// 새 키로 로테이션
await keyStore.addKey('key-2024-02');

// 구 키로 서명된 토큰도 여전히 검증 가능
const payload = await keyStore.verifyToken(token);

설명

이것이 하는 일: 키 로테이션은 여러 개의 암호화 키를 관리하고, 각 토큰에 어떤 키로 서명했는지 표시하여, 키 변경 시에도 기존 토큰들이 만료될 때까지 유효하게 유지되도록 합니다. 첫 번째로, KeyStore 클래스는 여러 키를 Map 구조로 저장합니다.

각 키는 고유한 ID(kid)를 가지며, 공개키와 개인키 쌍으로 구성됩니다. addKey() 메서드는 RS256 알고리즘용 RSA 키 쌍을 생성하는데, 이는 비대칭 암호화 방식으로 개인키는 서명에만 사용하고 공개키는 검증에 사용합니다.

이렇게 하면 여러 서비스가 공개키만 공유받아 토큰을 검증할 수 있어 마이크로서비스 아키텍처에 이상적입니다. 두 번째로, signToken() 메서드가 토큰을 생성할 때 setProtectedHeaderkid를 포함시킵니다.

kid는 나중에 토큰을 검증할 때 어떤 키를 사용해야 하는지 알려주는 핵심 정보입니다. 내부적으로는 현재 활성 키(currentKeyId)의 개인키를 사용하여 서명하므로, 항상 최신 키로 새 토큰을 발급합니다.

세 번째로, verifyToken() 메서드는 토큰의 헤더를 먼저 디코딩하여 kid를 추출합니다. 그 다음 해당 kid에 맞는 공개키를 찾아서 검증을 수행합니다.

이 과정 덕분에 새 키(key-2024-02)로 전환한 후에도 구 키(key-2024-01)로 서명된 토큰을 여전히 검증할 수 있습니다. 구 키는 새로 서명된 모든 토큰이 만료될 때까지(예: 2시간 후) 유지하다가 안전하게 제거할 수 있습니다.

여러분이 이 코드를 사용하면 보안 침해가 발생해도 사용자를 강제 로그아웃시키지 않고 안전하게 키를 전환할 수 있습니다. 실무에서의 이점은 첫째, 규정 준수(예: PCI-DSS는 암호화 키 정기 교체 요구), 둘째, 보안 사고 시 빠른 대응(침해된 키만 무효화), 셋째, 제로 다운타임 배포(키 변경이 서비스에 영향을 주지 않음)입니다.

실전 팁

💡 키 로테이션 주기는 토큰의 최대 유효 기간보다 길게 설정하세요. 예를 들어 토큰이 2시간 유효하다면, 최소 2시간은 구 키를 유지해야 합니다. 실무에서는 보통 30-90일 주기로 로테이션합니다. 💡 프로덕션 환경에서는 JWKS(JSON Web Key Set) 엔드포인트를 구현하여 공개키를 배포하세요. 예: GET /api/.well-known/jwks.json. 이렇게 하면 다른 마이크로서비스들이 자동으로 최신 키를 가져올 수 있습니다. 💡 키 로테이션을 자동화하려면 cron job이나 AWS Lambda 같은 스케줄러를 사용하세요. 매달 1일 새벽에 새 키를 생성하고, 3개월이 지난 키는 자동 삭제하는 식으로 구성할 수 있습니다. 💡 키 생성 시 createdAt 타임스탬프를 저장하여 키의 나이를 추적하세요. 모니터링 대시보드에서 키가 90일을 넘으면 경고를 표시하도록 설정하면 좋습니다. 💡 비상 상황 대비: 키 유출이 의심되면 즉시 새 키를 생성하고 currentKeyId를 변경한 후, 유출된 키는 블랙리스트에 추가하여 해당 키로 서명된 토큰은 모두 거부하세요. 이를 위해 verifyToken에 블랙리스트 체크 로직을 추가해야 합니다.


3. 클레임 검증(Claims Validation) - 토큰 남용 방지하기

시작하며

여러분이 만든 API가 있는데, 어느 날 로그를 보니 특정 사용자가 1년 전에 발급받은 토큰으로 여전히 접근하고 있다는 사실을 발견했습니다. 더 심각한 것은 그 사용자가 이미 탈퇴한 계정이라는 점입니다.

이런 일은 생각보다 자주 발생합니다. 토큰의 서명만 검증하고 만료 시간이나 발급자, 대상 등의 클레임을 제대로 검증하지 않으면 심각한 보안 취약점이 생깁니다.

공격자가 오래된 토큰을 재사용하거나, 다른 서비스용 토큰을 가져다 쓰는 공격이 가능해집니다. 바로 이럴 때 필요한 것이 철저한 클레임 검증입니다.

표준 클레임(exp, iat, nbf, iss, aud 등)과 커스텀 클레임을 모두 검증하여 토큰이 현재 컨텍스트에 적합한지 확인해야 합니다.

개요

간단히 말해서, 클레임 검증은 JWT의 페이로드에 담긴 정보들이 현재 요청에 적절한지 확인하여 토큰 남용과 권한 상승 공격을 방지하는 보안 메커니즘입니다. 실무에서는 특히 멀티 테넌트 환경이나 여러 마이크로서비스가 있는 시스템에서 필수적입니다.

예를 들어, 회사 A의 관리자 토큰을 회사 B의 API에 사용하는 것을 막거나, 모바일 앱용 토큰을 웹 API에서 거부하는 등의 정책을 구현할 수 있습니다. 기존에는 단순히 서명만 확인했다면, 이제는 토큰이 누가 발급했는지(iss), 누구를 위한 것인지(aud), 언제 발급되었는지(iat), 언제부터 유효한지(nbf), 언제 만료되는지(exp) 등을 모두 검증합니다.

추가로 커스텀 클레임으로 사용자 권한, 테넌트 ID, IP 주소 등도 검증할 수 있습니다. 클레임 검증의 핵심 특징은 첫째, 다층 보안(서명 + 클레임 검증으로 이중 방어), 둘째, 컨텍스트 인식 보안(요청 상황에 맞는 토큰만 허용), 셋째, 타임윈도우 공격 방지(시간 관련 클레임으로 재사용 공격 차단)입니다.

이러한 특징들이 OAuth 2.0이나 OpenID Connect 같은 표준 프로토콜에서도 필수 요구사항으로 지정되어 있습니다.

코드 예제

import { SignJWT, jwtVerify } from 'jose';

// 토큰 생성 - 풍부한 클레임 포함
async function createTokenWithClaims(userId, tenantId, secret) {
  const secretKey = new TextEncoder().encode(secret);

  return await new SignJWT({
    // 커스텀 클레임
    userId: userId,
    tenantId: tenantId,
    role: 'admin',
    permissions: ['read:users', 'write:users'],
    ipAddress: '192.168.1.100'
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer('https://api.myservice.com')  // 발급자
    .setAudience('https://myservice.com')     // 대상 서비스
    .setSubject(userId.toString())            // 주체(사용자)
    .setIssuedAt()                            // 발급 시간
    .setNotBefore(Math.floor(Date.now() / 1000)) // 유효 시작 시간
    .setExpirationTime('2h')                  // 만료 시간
    .setJti('unique-token-id-12345')          // 토큰 고유 ID
    .sign(secretKey);
}

// 토큰 검증 - 모든 클레임 철저히 확인
async function verifyTokenWithClaims(token, expectedTenantId, secret) {
  const secretKey = new TextEncoder().encode(secret);

  try {
    const { payload } = await jwtVerify(token, secretKey, {
      // 표준 클레임 검증
      issuer: 'https://api.myservice.com',
      audience: 'https://myservice.com',
      // 시간 검증 (자동으로 exp, nbf, iat 확인)
      clockTolerance: 10  // 시간 차이 10초까지 허용
    });

    // 커스텀 클레임 검증
    if (payload.tenantId !== expectedTenantId) {
      throw new Error('Invalid tenant');
    }

    if (!payload.permissions?.includes('read:users')) {
      throw new Error('Insufficient permissions');
    }

    return payload;
  } catch (error) {
    console.error('Token validation failed:', error.message);
    throw error;
  }
}

// 사용 예시
const token = await createTokenWithClaims(123, 'tenant-abc', 'secret');
const payload = await verifyTokenWithClaims(token, 'tenant-abc', 'secret');
console.log('Validated user:', payload.userId);

설명

이것이 하는 일: 클레임 검증은 JWT의 서명뿐만 아니라 토큰에 담긴 모든 메타데이터를 확인하여, 토큰이 올바른 발급자로부터 왔는지, 올바른 서비스를 대상으로 하는지, 시간적으로 유효한지, 그리고 비즈니스 규칙에 부합하는지를 종합적으로 검증합니다. 첫 번째로, createTokenWithClaims 함수는 JWT에 풍부한 클레임을 설정합니다.

setIssuer는 이 토큰을 누가 발급했는지 명시하는데, 실무에서는 보통 인증 서버의 URL을 사용합니다. setAudience는 이 토큰이 어떤 서비스를 위한 것인지 지정하여, 토큰이 다른 서비스에서 오용되는 것을 방지합니다.

setJti(JWT ID)는 토큰의 고유 식별자로, 이를 Redis에 저장하면 토큰 블랙리스트나 원타임 토큰 기능을 구현할 수 있습니다. 두 번째로, 시간 관련 클레임들이 설정됩니다.

setExpirationTime('2h')는 2시간 후 자동 만료를 의미하고, setNotBefore는 토큰이 언제부터 유효한지 지정합니다(미래 시간으로 설정하면 예약 토큰 생성 가능). setIssuedAt는 토큰 발급 시간을 기록하여 나중에 "이 토큰은 비밀번호 변경 전에 발급되었으므로 무효"같은 정책을 구현할 수 있게 합니다.

이러한 시간 클레임들은 리플레이 공격을 막는 핵심 방어 메커니즘입니다. 세 번째로, verifyTokenWithClaims 함수는 jwtVerify의 옵션을 통해 표준 클레임을 자동 검증합니다.

issueraudience가 예상 값과 다르면 자동으로 에러가 발생합니다. clockTolerance는 서버 간 시간 차이를 고려하여 10초 정도의 여유를 주는데, 이는 분산 시스템에서 필수적입니다.

그 다음 커스텀 클레임(tenantId, permissions)을 명시적으로 검증하여 멀티 테넌트 보안과 권한 기반 접근 제어를 구현합니다. 여러분이 이 코드를 사용하면 토큰 기반 공격의 대부분을 차단할 수 있습니다.

실무에서의 이점은 첫째, 토큰 재사용 공격 방지(만료 시간 검증), 둘째, 크로스 서비스 공격 방지(audience 검증), 셋째, 권한 상승 공격 방지(커스텀 클레임 검증), 넷째, 감사 추적(jti로 토큰 사용 이력 추적) 등입니다.

실전 팁

💡 clockTolerance는 너무 크게 설정하지 마세요(권장: 10-30초). 너무 크면 만료된 토큰도 오래 유효하게 되어 보안이 약해집니다. 💡 프로덕션 환경에서는 aud 클레임을 반드시 검증하세요. 흔한 실수는 개발/스테이징/프로덕션 환경이 같은 토큰을 공유하는 것입니다. 환경별로 다른 aud 값을 사용하세요. 💡 민감한 작업(결제, 비밀번호 변경 등)에는 짧은 만료 시간(5-15분)의 별도 토큰을 사용하세요. 일반 액세스 토큰은 2시간, 민감한 작업용은 5분 이런 식으로 차별화하면 보안이 크게 향상됩니다. 💡 jti를 Redis에 저장하여 "이미 사용된 토큰" 체크를 구현하면 원타임 토큰을 만들 수 있습니다. 예: 비밀번호 재설정 링크는 한 번만 사용 가능하게. 💡 커스텀 클레임으로 passwordChangedAt 타임스탬프를 포함하세요. 토큰 검증 시 DB의 실제 passwordChangedAt과 비교하여, 비밀번호 변경 후 발급된 토큰만 허용할 수 있습니다. 이는 계정 탈취 시 즉시 모든 기존 토큰을 무효화하는 효과가 있습니다.


4. Refresh Token 패턴 - 장기 세션을 안전하게 유지하기

시작하며

여러분의 모바일 앱 사용자들이 이런 불만을 토로합니다. "앱을 켤 때마다 로그인하라고 하는데, 너무 불편해요!" 그렇다고 JWT의 만료 시간을 길게 설정하자니, 토큰이 유출되면 공격자가 한 달 내내 계정에 접근할 수 있는 보안 문제가 생깁니다.

이것은 보안과 사용자 경험 사이의 고전적인 딜레마입니다. 짧은 만료 시간은 안전하지만 불편하고, 긴 만료 시간은 편리하지만 위험합니다.

더 나쁜 것은 JWT는 한 번 발급되면 서버에서 강제로 무효화할 수 없다는 점입니다. 바로 이럴 때 필요한 것이 Refresh Token 패턴입니다.

짧은 수명의 액세스 토큰(15분)과 긴 수명의 리프레시 토큰(30일)을 조합하여, 보안을 유지하면서도 사용자가 매번 로그인하지 않아도 되게 만들 수 있습니다.

개요

간단히 말해서, Refresh Token 패턴은 두 종류의 토큰을 사용하는 전략입니다. 액세스 토큰은 실제 API 호출에 사용되며 짧은 수명(5-15분)을 가지고, 리프레시 토큰은 새 액세스 토큰을 발급받는 데만 사용되며 긴 수명(7-90일)을 가집니다.

실무에서는 특히 모바일 앱, SPA, 데스크톱 앱 등 사용자가 자주 재방문하는 애플리케이션에서 필수적입니다. 예를 들어, 뱅킹 앱에서는 보안상 액세스 토큰을 5분으로 짧게 가져가면서도, 리프레시 토큰으로 30일간 재로그인 없이 사용할 수 있게 합니다.

기존에는 단일 토큰만 사용했다면, 이제는 역할이 분리된 두 토큰을 사용합니다. 액세스 토큰은 메모리에만 저장하고(XSS 공격 대비), 리프레시 토큰은 HttpOnly 쿠키나 안전한 스토리지에 저장합니다(CSRF 공격 대비).

리프레시 토큰은 DB에 저장하여 필요시 무효화할 수 있습니다. Refresh Token 패턴의 핵심 특징은 첫째, 토큰 무효화 가능(리프레시 토큰은 DB에 있어서 삭제 가능), 둘째, 제한된 피해 범위(액세스 토큰 유출 시 최대 15분만 유효), 셋째, 토큰 로테이션(리프레시 토큰도 사용할 때마다 새로 발급하여 보안 강화)입니다.

이러한 특징들이 OAuth 2.0의 핵심 보안 메커니즘으로 채택되었습니다.

코드 예제

import { SignJWT, jwtVerify } from 'jose';
import crypto from 'crypto';

// 토큰 쌍 생성
async function createTokenPair(userId, secret) {
  const secretKey = new TextEncoder().encode(secret);

  // 액세스 토큰 - 짧은 수명, 많은 정보
  const accessToken = await new SignJWT({
    userId,
    type: 'access',
    permissions: ['read', 'write']
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('15m')  // 15분
    .sign(secretKey);

  // 리프레시 토큰 - 긴 수명, 최소 정보
  const refreshTokenId = crypto.randomUUID();
  const refreshToken = await new SignJWT({
    userId,
    type: 'refresh',
    jti: refreshTokenId  // DB 저장용 고유 ID
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('30d')  // 30일
    .sign(secretKey);

  // 리프레시 토큰을 DB에 저장 (예: PostgreSQL, Redis)
  await saveRefreshTokenToDB(userId, refreshTokenId, new Date(Date.now() + 30 * 24 * 60 * 60 * 1000));

  return { accessToken, refreshToken };
}

// 리프레시 토큰으로 새 액세스 토큰 발급
async function refreshAccessToken(refreshToken, secret) {
  const secretKey = new TextEncoder().encode(secret);

  // 리프레시 토큰 검증
  const { payload } = await jwtVerify(refreshToken, secretKey);

  if (payload.type !== 'refresh') {
    throw new Error('Invalid token type');
  }

  // DB에서 토큰이 유효한지 확인
  const isValid = await checkRefreshTokenInDB(payload.jti);
  if (!isValid) {
    throw new Error('Refresh token revoked or not found');
  }

  // 토큰 로테이션: 구 리프레시 토큰 무효화 & 새 토큰 쌍 발급
  await revokeRefreshTokenInDB(payload.jti);
  return await createTokenPair(payload.userId, secret);
}

// DB 저장 함수 (예시)
async function saveRefreshTokenToDB(userId, tokenId, expiresAt) {
  // await db.query('INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES ($1, $2, $3)', [userId, tokenId, expiresAt]);
}

async function checkRefreshTokenInDB(tokenId) {
  // const result = await db.query('SELECT * FROM refresh_tokens WHERE token_id = $1 AND expires_at > NOW()', [tokenId]);
  // return result.rows.length > 0;
  return true; // 예시
}

async function revokeRefreshTokenInDB(tokenId) {
  // await db.query('DELETE FROM refresh_tokens WHERE token_id = $1', [tokenId]);
}

설명

이것이 하는 일: Refresh Token 패턴은 보안에 민감한 API 호출용 단기 토큰과 토큰 갱신용 장기 토큰을 분리하여, 토큰 유출 시 피해를 최소화하면서도 사용자가 자주 로그인하지 않아도 되게 만듭니다. 첫 번째로, createTokenPair 함수는 두 가지 성격이 다른 토큰을 생성합니다.

액세스 토큰은 15분의 짧은 수명을 가지며 사용자의 권한 정보를 포함합니다. 이 토큰은 매 API 요청의 Authorization 헤더에 실려서 전송됩니다.

반면 리프레시 토큰은 30일의 긴 수명을 가지며 최소한의 정보만 담고 있습니다. 중요한 점은 리프레시 토큰에 jti(고유 ID)를 부여하여 DB에 저장한다는 것입니다.

이렇게 하면 서버가 언제든 리프레시 토큰을 무효화할 수 있어 "로그아웃" 기능이 제대로 작동합니다. 두 번째로, 클라이언트는 액세스 토큰으로 API를 호출하다가 401 Unauthorized 에러를 받으면, 자동으로 리프레시 토큰을 /auth/refresh 엔드포인트에 보내 새 액세스 토큰을 받습니다.

이 과정에서 refreshAccessToken 함수는 먼저 리프레시 토큰의 서명과 만료 시간을 검증하고, 그 다음 DB에 해당 jti가 존재하고 만료되지 않았는지 확인합니다. 이중 검증 덕분에 JWT의 무상태성과 DB의 제어 가능성을 모두 활용할 수 있습니다.

세 번째로, 토큰 로테이션 메커니즘이 작동합니다. 리프레시 토큰을 한 번 사용하면 즉시 DB에서 삭제하고, 새로운 액세스 토큰과 리프레시 토큰 쌍을 발급합니다.

이 방식은 OAuth 2.1의 권장사항으로, 리프레시 토큰이 유출되어도 공격자가 한 번 사용하면 정상 사용자의 다음 갱신 요청이 실패하므로 침해를 즉시 감지할 수 있습니다. 최종적으로 새 토큰 쌍을 클라이언트에 반환하면 클라이언트는 이를 저장하고 계속 사용합니다.

여러분이 이 코드를 사용하면 엔터프라이즈급 인증 시스템을 구축할 수 있습니다. 실무에서의 이점은 첫째, 액세스 토큰 유출 시 피해가 15분으로 제한됨(리프레시 토큰은 다른 저장소에 있어 동시 유출 가능성 낮음), 둘째, 사용자가 30일간 재로그인 없이 사용 가능(UX 향상), 셋째, 계정 탈퇴/비밀번호 변경 시 모든 리프레시 토큰을 DB에서 삭제하여 즉시 로그아웃 가능, 넷째, 토큰 로테이션으로 리프레시 토큰 유출 시 조기 감지 가능합니다.

실전 팁

💡 액세스 토큰은 localStorage가 아닌 메모리(React state, Vuex)에 저장하세요. XSS 공격으로 localStorage는 쉽게 탈취되지만 메모리는 페이지를 벗어나면 사라집니다. 리프레시 토큰은 HttpOnly 쿠키에 저장하는 것이 가장 안전합니다. 💡 리프레시 토큰 엔드포인트에는 반드시 rate limiting을 적용하세요. 예: 같은 IP에서 분당 5회 이상 요청 시 차단. 이렇게 하면 브루트포스 공격을 막을 수 있습니다. 💡 DB에 리프레시 토큰을 저장할 때 user_agent, ip_address도 함께 저장하세요. 토큰 갱신 시 이 정보가 변경되었다면 의심스러운 활동으로 간주하고 추가 인증(2FA)을 요구할 수 있습니다. 💡 프로덕션 환경에서는 리프레시 토큰 테이블에 인덱스를 반드시 추가하세요: CREATE INDEX idx_token_id ON refresh_tokens(token_id). 토큰 검증이 모든 요청에서 일어나므로 성능이 중요합니다. 💡 "모든 기기에서 로그아웃" 기능을 구현하려면 DELETE FROM refresh_tokens WHERE user_id = $1을 실행하세요. 사용자가 비밀번호를 변경하거나 계정 보안 위협을 감지했을 때 자동으로 실행되도록 설정하면 좋습니다.


5. 토큰 블랙리스트 - 즉시 로그아웃 구현하기

시작하며

여러분이 관리자로 일하는데, 퇴사한 직원의 계정을 비활성화했습니다. 하지만 그 직원이 이미 발급받은 JWT로 2시간 동안 계속 시스템에 접근할 수 있다는 사실을 알게 됩니다.

이것은 심각한 보안 문제입니다. JWT의 가장 큰 단점은 무상태성(stateless)입니다.

한 번 발급된 토큰은 만료될 때까지 서버가 무효화할 수 없습니다. 사용자가 로그아웃 버튼을 눌러도 토큰은 여전히 유효하며, 공격자가 그 토큰을 가지고 있다면 계속 사용할 수 있습니다.

바로 이럴 때 필요한 것이 토큰 블랙리스트입니다. Redis 같은 빠른 저장소에 무효화된 토큰의 ID를 저장하고, 매 요청마다 블랙리스트를 확인하여 해당 토큰을 거부하는 메커니즘입니다.

개요

간단히 말해서, 토큰 블랙리스트는 무효화해야 할 JWT의 식별자(jti)를 Redis나 메모리 DB에 저장하고, 토큰 검증 시 이 리스트를 확인하여 블랙리스트에 있는 토큰은 거부하는 보안 패턴입니다. 실무에서는 특히 로그아웃 기능, 비밀번호 변경 시 기존 세션 무효화, 관리자가 특정 사용자를 강제 로그아웃시키는 기능 등을 구현할 때 필수적입니다.

예를 들어, 보안 사고가 발생했을 때 의심스러운 토큰을 즉시 무효화하여 피해를 막을 수 있습니다. 기존에는 JWT를 발급하면 만료될 때까지 기다릴 수밖에 없었다면, 이제는 능동적으로 특정 토큰을 무효화할 수 있습니다.

토큰에 jti 클레임(JWT ID)을 포함시키고, 로그아웃 시 이 ID를 블랙리스트에 추가하며, 토큰 검증 시 블랙리스트를 체크합니다. 토큰 블랙리스트의 핵심 특징은 첫째, 즉시 무효화(사용자 로그아웃 즉시 토큰이 무효화됨), 둘째, 선택적 무효화(특정 토큰만 골라서 차단 가능), 셋째, TTL 기반 자동 정리(만료된 토큰은 자동으로 블랙리스트에서 제거)입니다.

이러한 특징들이 JWT의 단점을 보완하면서도 대부분의 요청은 여전히 무상태로 빠르게 처리할 수 있게 해줍니다.

코드 예제

import { SignJWT, jwtVerify } from 'jose';
import Redis from 'ioredis';
import crypto from 'crypto';

const redis = new Redis();

// 토큰 생성 - jti 포함
async function createToken(userId, secret) {
  const secretKey = new TextEncoder().encode(secret);
  const tokenId = crypto.randomUUID();

  return await new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setJti(tokenId)  // 토큰 고유 ID
    .setExpirationTime('2h')
    .sign(secretKey);
}

// 로그아웃 - 토큰을 블랙리스트에 추가
async function logout(token) {
  // 토큰에서 jti와 exp 추출
  const parts = token.split('.');
  const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());

  const jti = payload.jti;
  const exp = payload.exp;
  const now = Math.floor(Date.now() / 1000);
  const ttl = exp - now;  // 남은 유효 시간 (초)

  // Redis에 저장 - TTL 설정으로 자동 삭제
  if (ttl > 0) {
    await redis.setex(`blacklist:${jti}`, ttl, '1');
  }
}

// 토큰 검증 - 블랙리스트 확인 포함
async function verifyToken(token, secret) {
  const secretKey = new TextEncoder().encode(secret);

  // 1. 서명 및 만료 시간 검증
  const { payload } = await jwtVerify(token, secretKey);

  // 2. 블랙리스트 확인
  const isBlacklisted = await redis.exists(`blacklist:${payload.jti}`);
  if (isBlacklisted) {
    throw new Error('Token has been revoked');
  }

  return payload;
}

// 특정 사용자의 모든 토큰 무효화 (비밀번호 변경 시)
async function revokeAllUserTokens(userId) {
  // 실제로는 DB에서 해당 사용자의 모든 활성 토큰 jti를 가져와야 함
  // 여기서는 개념만 표현
  const activeTokenIds = await getActiveTokenIdsFromDB(userId);

  for (const jti of activeTokenIds) {
    await redis.setex(`blacklist:${jti}`, 7200, '1');  // 2시간
  }
}

async function getActiveTokenIdsFromDB(userId) {
  // DB 조회 로직
  return [];  // 예시
}

설명

이것이 하는 일: 토큰 블랙리스트는 JWT의 무상태성으로 인한 무효화 불가 문제를 해결하기 위해, 무효화해야 할 토큰의 고유 ID를 빠른 저장소(Redis)에 저장하고 매 요청마다 확인하는 하이브리드 접근 방식입니다. 첫 번째로, createToken 함수는 토큰에 jti(JWT ID) 클레임을 추가합니다.

이것은 UUID 같은 고유 식별자로, 나중에 이 특정 토큰만 골라서 무효화할 수 있게 해줍니다. jti가 없으면 블랙리스트 방식을 사용할 수 없습니다.

왜냐하면 같은 사용자의 여러 토큰을 구분할 방법이 없기 때문입니다. 예를 들어, 사용자가 모바일과 웹에서 동시에 로그인했다면 각각 다른 jti를 가지며, 모바일에서만 로그아웃해도 웹 세션은 유지됩니다.

두 번째로, logout 함수는 토큰의 페이로드를 디코딩하여 jtiexp(만료 시간)를 추출합니다. 중요한 점은 Redis에 저장할 때 TTL(Time To Live)을 토큰의 남은 수명으로 설정한다는 것입니다.

예를 들어, 토큰이 2시간짜리인데 1시간이 지났다면 블랙리스트 항목도 1시간 후 자동 삭제됩니다. 이렇게 하면 블랙리스트가 무한정 커지는 것을 방지할 수 있습니다.

어차피 만료된 토큰은 자동으로 거부되므로 블랙리스트에 남아있을 필요가 없습니다. 세 번째로, verifyToken 함수는 두 단계 검증을 수행합니다.

먼저 jwtVerify로 토큰의 서명과 만료 시간을 검증하고, 그 다음 Redis에서 blacklist:${jti} 키가 존재하는지 확인합니다. 존재한다면 이 토큰은 로그아웃되었거나 무효화된 것이므로 에러를 던집니다.

Redis의 exists 명령은 O(1) 시간 복잡도로 매우 빠르므로(약 0.1ms) 성능 영향이 거의 없습니다. revokeAllUserTokens 함수는 비밀번호 변경 같은 상황에서 사용자의 모든 활성 토큰을 한 번에 무효화합니다.

여러분이 이 코드를 사용하면 JWT의 장점(무상태, 빠른 검증)을 유지하면서도 세션의 장점(즉시 무효화)을 얻을 수 있습니다. 실무에서의 이점은 첫째, 진짜 로그아웃 구현(사용자가 로그아웃하면 토큰 즉시 무효화), 둘째, 보안 사고 대응(의심스러운 토큰 즉시 차단), 셋째, 비밀번호 변경 시 모든 기기 강제 로그아웃, 넷째, 관리자가 특정 사용자를 원격에서 로그아웃시키는 기능 구현입니다.

실전 팁

💡 Redis 대신 Memcached나 인메모리 Map을 사용할 수도 있지만, Redis를 강력히 권장합니다. Redis는 TTL 자동 만료, 클러스터링, 영속성(persistence) 등을 지원하여 프로덕션 환경에 적합합니다. 💡 블랙리스트 체크를 미들웨어에 통합하세요. Express.js 예시: app.use(async (req, res, next) => { const token = extractToken(req); await verifyToken(token, secret); next(); }). 이렇게 하면 모든 엔드포인트에서 자동으로 블랙리스트를 확인합니다. 💡 대규모 시스템에서는 Bloom Filter를 사용하여 메모리를 절약할 수 있습니다. Bloom Filter는 "이 jti가 블랙리스트에 없을 가능성이 100%"를 빠르게 판단하여 대부분의 경우 Redis 조회를 건너뛸 수 있습니다. 💡 블랙리스트 적중률을 모니터링하세요. CloudWatch나 Prometheus로 blacklist_hits_total 메트릭을 추적하면, 비정상적으로 많은 무효화된 토큰 사용 시도(공격 가능성)를 감지할 수 있습니다. 💡 "모든 사용자 강제 로그아웃" 같은 비상 기능을 구현하려면, 블랙리스트 외에 "글로벌 무효화 타임스탬프"를 Redis에 저장하세요. 예: global_revoke_after: 2024-01-01T00:00:00Z. 이 시간 이전에 발급된 모든 토큰을 거부하면 됩니다.


6. 범위 제한(Scope) - 최소 권한 원칙 적용하기

시작하며

여러분이 소셜 로그인을 구현했는데, 사용자가 "이 앱이 내 이메일만 읽으면 되는데 왜 프로필 수정 권한까지 요청하나요?"라고 불만을 제기합니다. 또는 내부 시스템에서 결제 서비스가 사용자 관리 API도 호출할 수 있어서 권한 분리가 제대로 안 되는 상황입니다.

이런 문제는 토큰에 과도한 권한을 부여할 때 발생합니다. 한 토큰이 모든 것을 할 수 있으면 토큰이 유출되었을 때 피해가 막대합니다.

또한 사용자 입장에서도 앱을 신뢰하기 어렵습니다. 바로 이럴 때 필요한 것이 OAuth 2.0 스타일의 스코프(Scope) 기반 권한 관리입니다.

토큰에 구체적인 권한 범위를 명시하고, API 엔드포인트마다 필요한 스코프를 요구하여 최소 권한 원칙을 적용할 수 있습니다.

개요

간단히 말해서, 스코프는 JWT에 담기는 권한의 구체적인 범위를 정의하는 문자열 집합으로, "이 토큰은 사용자 정보를 읽을 수만 있고 수정할 수 없다" 같은 세밀한 접근 제어를 가능하게 합니다. 실무에서는 특히 마이크로서비스 아키텍처, 써드파티 API 통합, 모바일 앱의 선택적 권한 요청 등에서 필수적입니다.

예를 들어, Slack 봇이 메시지를 읽을 수는 있지만 삭제는 못 하게 하거나, 모바일 앱이 사진 업로드는 할 수 있지만 결제는 못 하게 제한할 수 있습니다. 기존에는 역할 기반 접근 제어(RBAC)만 사용했다면(role: 'admin'), 이제는 더 세밀한 권한 제어가 가능합니다.

예를 들어, scope: 'read:users write:posts'처럼 리소스와 액션을 명시합니다. 같은 admin이라도 어떤 토큰은 읽기만, 어떤 토큰은 읽기+쓰기가 가능하게 분리할 수 있습니다.

스코프의 핵심 특징은 첫째, 세밀한 권한 제어(리소스와 액션을 조합한 구체적 권한 표현), 둘째, 사용자 동의 기반(사용자가 앱에 어떤 권한을 줄지 선택 가능), 셋째, 표준 문법(resource:action 형식으로 직관적)입니다. 이러한 특징들이 OAuth 2.0, Google API, GitHub API 등 모든 주요 API에서 채택되어 업계 표준이 되었습니다.

코드 예제

import { SignJWT, jwtVerify } from 'jose';

// 스코프를 포함한 토큰 생성
async function createTokenWithScopes(userId, requestedScopes, secret) {
  const secretKey = new TextEncoder().encode(secret);

  // 사용자가 가진 전체 권한
  const userPermissions = await getUserPermissions(userId);

  // 요청된 스코프와 사용자 권한의 교집합
  const grantedScopes = requestedScopes.filter(scope =>
    userPermissions.includes(scope)
  );

  return await new SignJWT({
    userId,
    scope: grantedScopes.join(' ')  // 공백으로 구분된 문자열
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('1h')
    .sign(secretKey);
}

// 스코프 검증 미들웨어
function requireScope(...requiredScopes) {
  return async (req, res, next) => {
    try {
      const token = req.headers.authorization?.replace('Bearer ', '');
      const secret = new TextEncoder().encode(process.env.JWT_SECRET);

      const { payload } = await jwtVerify(token, secret);
      const tokenScopes = payload.scope?.split(' ') || [];

      // 필요한 모든 스코프가 있는지 확인
      const hasAllScopes = requiredScopes.every(scope =>
        tokenScopes.includes(scope)
      );

      if (!hasAllScopes) {
        return res.status(403).json({
          error: 'Insufficient scope',
          required: requiredScopes,
          granted: tokenScopes
        });
      }

      req.user = payload;
      next();
    } catch (error) {
      res.status(401).json({ error: 'Invalid token' });
    }
  };
}

// 사용 예시 - Express.js 라우트
app.get('/api/users', requireScope('read:users'), async (req, res) => {
  // 사용자 목록 조회
});

app.post('/api/users', requireScope('write:users'), async (req, res) => {
  // 사용자 생성
});

app.delete('/api/users/:id', requireScope('delete:users', 'admin:users'), async (req, res) => {
  // 사용자 삭제 - 두 스코프 모두 필요
});

async function getUserPermissions(userId) {
  // DB에서 사용자 권한 조회
  return ['read:users', 'write:posts', 'read:posts'];
}

설명

이것이 하는 일: 스코프 기반 권한 관리는 토큰이 수행할 수 있는 작업을 리소스와 액션의 조합으로 명시하여, 과도한 권한 부여를 방지하고 보안 원칙인 "최소 권한의 원칙(Principle of Least Privilege)"을 실현합니다. 첫 번째로, createTokenWithScopes 함수는 사용자가 요청한 스코프와 실제로 가진 권한의 교집합만 토큰에 부여합니다.

예를 들어, 써드파티 앱이 ['read:users', 'delete:users', 'admin:system']을 요청했지만 사용자는 일반 사용자라서 ['read:users', 'write:posts']만 가지고 있다면, 최종적으로 'read:users'만 토큰에 포함됩니다. 이 방식은 OAuth 2.0의 동의 화면과 같은 개념으로, 사용자가 앱에 과도한 권한을 주는 것을 방지합니다.

스코프는 resource:action 형식(예: read:users, write:posts)을 따르며, 공백으로 구분하여 하나의 문자열로 저장합니다. 두 번째로, requireScope 미들웨어는 각 API 엔드포인트에서 필요한 스코프를 선언적으로 요구합니다.

예를 들어, /api/users GET 엔드포인트는 read:users 스코프만 요구하지만, DELETE 엔드포인트는 delete:usersadmin:users 두 가지를 모두 요구합니다. 내부적으로는 토큰의 scope 클레임을 공백으로 분할하여 배열로 만든 후, every() 메서드로 필요한 스코프가 모두 포함되어 있는지 확인합니다.

하나라도 부족하면 403 Forbidden 에러를 반환하며, 어떤 스코프가 필요한지 명시적으로 알려줍니다. 세 번째로, 실제 라우트에서는 미들웨어를 체인으로 연결하여 사용합니다.

app.delete('/api/users/:id', requireScope('delete:users', 'admin:users'), ...)처럼 여러 스코프를 요구할 수 있으며, 이는 "AND" 조건으로 작동합니다. 만약 "OR" 조건이 필요하다면(예: admin 또는 delete:users 중 하나만 있어도 OK) 별도의 requireAnyScope 미들웨어를 만들 수 있습니다.

이런 선언적 접근 방식은 코드 가독성을 높이고, API 문서 자동 생성 시 필요한 권한을 명확히 표시할 수 있게 해줍니다. 여러분이 이 코드를 사용하면 Zero Trust 보안 모델을 실현할 수 있습니다.

실무에서의 이점은 첫째, 토큰 유출 시 피해 범위 제한(읽기 전용 토큰이 유출되어도 데이터 변조 불가), 둘째, 사용자 신뢰 향상(앱이 필요한 권한만 요청), 셋째, 감사 추적(누가 어떤 스코프로 어떤 작업을 했는지 로그 기록 가능), 넷째, 마이크로서비스 간 안전한 통신(각 서비스가 필요한 최소 권한만 가진 토큰 사용)입니다.

실전 팁

💡 스코프 네이밍 컨벤션을 일관되게 유지하세요. 권장 형식: <resource>:<action> (예: users:read, posts:write, billing:admin). 팀 전체가 같은 규칙을 따라야 나중에 관리가 쉽습니다. 💡 와일드카드를 지원하면 편리합니다. 예를 들어, users:*users:read, users:write, users:delete를 모두 포함하는 식입니다. 하지만 프로덕션에서는 보안상 명시적 스코프만 사용하는 것이 더 안전합니다. 💡 OpenAPI(Swagger) 문서에 각 엔드포인트의 필요한 스코프를 명시하세요. 예: security: [{ bearerAuth: ['read:users'] }]. 이렇게 하면 API 사용자가 어떤 권한이 필요한지 쉽게 알 수 있습니다. 💡 스코프 계층 구조를 구현하면 더 유연합니다. 예를 들어, admin:users를 가진 사람은 자동으로 read:users, write:users, delete:users도 가진 것으로 간주하는 식입니다. 이를 위해 스코프 상속 맵을 만드세요. 💡 써드파티 앱에는 절대 admin 같은 전체 권한 스코프를 주지 마세요. 대신 구체적인 스코프만 부여하고, 민감한 작업(결제, 계정 삭제 등)은 사용자 재인증을 요구하세요. Google이 "민감한 스코프"를 별도로 관리하는 것을 참고하세요.


7. 토큰 지문 인식(Token Fingerprinting) - XSS 공격 방어하기

시작하며

여러분의 웹 애플리케이션에 XSS 취약점이 있어서 공격자가 JavaScript로 localStorage의 JWT를 훔쳐갈 수 있다는 보고를 받았습니다. 토큰을 HttpOnly 쿠키로 옮기는 것이 정석이지만, SPA 아키텍처상 쿠키를 사용하기 어려운 상황입니다.

이것은 모던 웹 개발의 고질적인 문제입니다. SPA에서는 JavaScript가 토큰에 접근해야 API를 호출할 수 있는데, 그렇다고 localStorage에 저장하면 XSS에 취약합니다.

HTTPS만으로는 XSS를 막을 수 없습니다. 바로 이럴 때 필요한 것이 토큰 지문 인식(Token Fingerprinting)입니다.

토큰을 두 부분으로 나누어 한 부분은 HttpOnly 쿠키에, 다른 부분은 토큰 자체에 담아서, 두 부분이 모두 일치해야만 유효하도록 만드는 기법입니다.

개요

간단히 말해서, 토큰 지문 인식은 랜덤 값(지문)을 생성하여 그 해시를 토큰에 포함시키고, 원본 값은 HttpOnly 쿠키에 저장하여, 공격자가 토큰만 훔쳐가도 쿠키 없이는 사용할 수 없게 만드는 보안 패턴입니다. 실무에서는 특히 XSS 취약점이 완전히 제거되기 어려운 대규모 SPA, 레거시 시스템과 통합해야 하는 경우, 다양한 써드파티 라이브러리를 사용하는 환경 등에서 추가 방어층으로 사용됩니다.

예를 들어, 콘텐츠 관리 시스템(CMS)처럼 사용자가 HTML을 작성할 수 있는 서비스에서는 XSS 위험이 높아 토큰 지문 인식이 중요합니다. 기존에는 "토큰을 훔치면 끝"이었다면, 이제는 "토큰과 쿠키 둘 다 필요"하게 만들어 공격 난이도를 크게 높입니다.

XSS로 localStorage는 읽을 수 있지만 HttpOnly 쿠키는 읽을 수 없으므로, 공격자는 토큰을 가져가도 사용할 수 없습니다. 토큰 지문 인식의 핵심 특징은 첫째, 다층 방어(토큰과 쿠키라는 두 가지 저장소 활용), 둘째, XSS 영향 최소화(토큰 유출만으로는 공격 불가), 셋째, 기존 인프라 변경 최소(JWT 구조는 유지하면서 추가 검증만)입니다.

이러한 특징들이 OWASP에서 권장하는 XSS 방어 전략 중 하나로 채택되었습니다.

코드 예제

import { SignJWT, jwtVerify } from 'jose';
import crypto from 'crypto';

// 토큰 생성 - 지문 포함
async function createTokenWithFingerprint(userId, secret) {
  const secretKey = new TextEncoder().encode(secret);

  // 랜덤 지문 생성 (32바이트)
  const fingerprint = crypto.randomBytes(32).toString('hex');

  // 지문의 SHA-256 해시 계산
  const fingerprintHash = crypto
    .createHash('sha256')
    .update(fingerprint)
    .digest('hex');

  // 토큰에는 해시만 포함
  const token = await new SignJWT({
    userId,
    fpt: fingerprintHash  // fingerprint hash
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('2h')
    .sign(secretKey);

  // 원본 지문은 별도 반환 (HttpOnly 쿠키에 저장할 용도)
  return { token, fingerprint };
}

// Express.js - 로그인 엔드포인트
app.post('/auth/login', async (req, res) => {
  const userId = await authenticateUser(req.body);
  const { token, fingerprint } = await createTokenWithFingerprint(userId, process.env.JWT_SECRET);

  // 토큰은 응답 바디로 (클라이언트가 localStorage나 메모리에 저장)
  // 지문은 HttpOnly 쿠키로 (JavaScript 접근 불가)
  res.cookie('__Secure-Fpt', fingerprint, {
    httpOnly: true,
    secure: true,      // HTTPS only
    sameSite: 'strict',
    maxAge: 7200000    // 2시간
  });

  res.json({ token });
});

// 토큰 검증 - 지문 일치 확인
async function verifyTokenWithFingerprint(token, fingerprintFromCookie, secret) {
  const secretKey = new TextEncoder().encode(secret);

  // 1. 토큰 서명 검증
  const { payload } = await jwtVerify(token, secretKey);

  // 2. 쿠키의 지문 해시와 토큰의 해시 비교
  const fingerprintHash = crypto
    .createHash('sha256')
    .update(fingerprintFromCookie)
    .digest('hex');

  if (payload.fpt !== fingerprintHash) {
    throw new Error('Token fingerprint mismatch - possible token theft');
  }

  return payload;
}

// Express.js - 보호된 엔드포인트 미들웨어
app.use(async (req, res, next) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');
    const fingerprint = req.cookies['__Secure-Fpt'];

    if (!token || !fingerprint) {
      return res.status(401).json({ error: 'Missing credentials' });
    }

    const payload = await verifyTokenWithFingerprint(token, fingerprint, process.env.JWT_SECRET);
    req.user = payload;
    next();
  } catch (error) {
    res.status(401).json({ error: error.message });
  }
});

async function authenticateUser(credentials) {
  return 123; // 예시
}

설명

이것이 하는 일: 토큰 지문 인식은 토큰과 쿠키라는 두 가지 독립적인 저장소에 연결된 정보를 분산 저장하여, 공격자가 XSS를 통해 한쪽만 탈취해도 사용할 수 없게 만드는 심층 방어 전략입니다. 첫 번째로, createTokenWithFingerprint 함수는 32바이트(256비트)의 암호학적으로 안전한 랜덤 값을 생성합니다.

이것이 "지문"입니다. 그 다음 이 지문의 SHA-256 해시를 계산하여 토큰의 fpt 클레임에 포함시킵니다.

중요한 점은 토큰에는 해시만 들어가고 원본 지문은 들어가지 않는다는 것입니다. 이는 일방향 함수의 특성을 활용한 것으로, 해시에서 원본을 역산할 수 없습니다.

따라서 공격자가 토큰을 훔쳐서 fpt 값을 보더라도 원본 지문을 알아낼 수 없습니다. 두 번째로, 로그인 엔드포인트는 토큰과 지문을 서로 다른 방식으로 클라이언트에 전달합니다.

토큰은 JSON 응답 바디로 보내서 클라이언트가 localStorage나 메모리에 저장하게 하고, 원본 지문은 __Secure-Fpt라는 이름의 HttpOnly 쿠키로 설정합니다. HttpOnly 플래그 덕분에 JavaScript로는 이 쿠키를 읽을 수 없습니다.

secure: true는 HTTPS에서만 전송되도록 하고, sameSite: 'strict'는 CSRF 공격을 방지합니다. 이렇게 하면 XSS 공격자는 document.cookie로 지문을 읽을 수 없습니다.

세 번째로, verifyTokenWithFingerprint 함수는 검증 시 두 가지를 확인합니다. 먼저 토큰의 서명과 만료 시간을 검증하고, 그 다음 쿠키에서 받은 지문을 SHA-256으로 해싱하여 토큰의 fpt 클레임과 비교합니다.

만약 공격자가 XSS로 localStorage의 토큰만 훔쳐서 자신의 브라우저에서 사용하려 하면, 쿠키가 없으므로 요청이 실패합니다. 반대로 쿠키만 훔쳐가도(사실상 불가능하지만) 토큰이 없으면 무용지물입니다.

두 가지가 정확히 일치하는 쌍이어야만 인증이 성공합니다. 여러분이 이 코드를 사용하면 XSS 취약점이 있어도 토큰 탈취 공격의 성공 확률을 크게 낮출 수 있습니다.

실무에서의 이점은 첫째, XSS 방어 심화(토큰만 훔치면 안 됨), 둘째, 기존 아키텍처 유지(SPA에서 계속 Authorization 헤더 사용 가능), 셋째, 공격 탐지 가능(지문 불일치 시 알림 발송), 넷째, CSRF도 함께 방어(쿠키에 SameSite 속성 적용)입니다.

실전 팁

💡 지문 쿠키 이름은 __Secure- 또는 __Host- 접두사를 사용하세요. 예: __Host-Fpt. 이 접두사들은 브라우저가 추가 보안 요구사항(HTTPS, secure 플래그 등)을 강제하여 공격자가 쿠키를 덮어쓰는 것을 방지합니다. 💡 프로덕션 환경에서는 토큰과 쿠키의 만료 시간을 동일하게 설정하세요. 둘 중 하나만 만료되면 인증이 실패하므로, 일관성을 유지해야 사용자 경험이 좋습니다. 💡 지문 불일치 시 보안 이벤트로 로그를 남기세요. 예: logger.warn('Token fingerprint mismatch', { userId, ip, userAgent }). 반복적인 불일치는 활발한 공격을 의미하므로 사용자에게 알림을 보내거나 계정을 일시 잠금할 수 있습니다. 💡 모바일 앱에서는 쿠키 대신 Keychain(iOS)이나 Keystore(Android)를 사용하세요. 지문을 안전한 저장소에 보관하고 앱 코드에서 토큰과 함께 검증하는 식으로 동일한 패턴을 적용할 수 있습니다. 💡 Content Security Policy(CSP)를 함께 적용하면 XSS 공격 자체를 줄일 수 있습니다. 예: Content-Security-Policy: script-src 'self'. 토큰 지문 인식은 XSS가 발생했을 때의 마지막 방어선이므로, CSP로 XSS를 먼저 막는 것이 더 좋습니다.


8. 비대칭 키 암호화(RS256) - 마이크로서비스에서 안전하게 검증하기

시작하며

여러분이 10개의 마이크로서비스를 운영하는데, 모든 서비스가 JWT를 검증해야 합니다. 대칭키(HS256)를 사용하면 모든 서비스에 같은 비밀키를 배포해야 하는데, 한 서비스라도 침해되면 공격자가 무제한으로 유효한 토큰을 만들 수 있습니다.

이것은 마이크로서비스 아키텍처의 고질적인 보안 문제입니다. 비밀키를 공유하는 서비스가 많아질수록 키 유출 위험도 비례해서 증가합니다.

또한 외부 파트너사나 써드파티 서비스에 토큰 검증 기능을 제공하고 싶을 때 비밀키를 절대 공유할 수 없습니다. 바로 이럴 때 필요한 것이 비대칭 키 암호화, 특히 RS256(RSA Signature with SHA-256) 알고리즘입니다.

개인키는 인증 서버만 가지고 토큰을 서명하고, 공개키는 모든 서비스에 배포하여 검증만 하게 만들 수 있습니다.

개요

간단히 말해서, RS256은 RSA 공개키/개인키 쌍을 사용하는 JWT 서명 알고리즘으로, 개인키로 서명하고 공개키로 검증하여 키 배포의 보안 문제를 해결합니다. 실무에서는 특히 마이크로서비스 아키텍처, 멀티 테넌트 SaaS, 공개 API 제공, 파트너사와의 통합 등에서 필수적입니다.

예를 들어, Auth0이나 Keycloak 같은 인증 서비스는 RS256을 기본으로 사용하며, 고객사가 공개키만 가져가서 토큰을 검증하게 합니다. 기존의 HS256(대칭키)에서는 서명과 검증에 같은 키를 사용했다면, RS256(비대칭키)에서는 서명은 개인키로, 검증은 공개키로 분리합니다.

공개키는 이름 그대로 공개해도 안전하므로, GitHub에 커밋하거나 HTTP로 배포해도 문제없습니다. RS256의 핵심 특징은 첫째, 키 분리(서명 권한과 검증 권한 분리), 둘째, 공개키 배포 안전(공개키 유출은 보안 문제 아님), 셋째, 표준 호환(OpenID Connect, SAML 등 모든 표준 지원)입니다.

이러한 특징들이 엔터프라이즈급 인증 시스템의 필수 요구사항이 되었습니다.

코드 예제

import { SignJWT, jwtVerify, generateKeyPair, exportJWK, importJWK } from 'jose';

// RSA 키 쌍 생성 (한 번만 실행, 결과를 파일로 저장)
async function generateRSAKeyPair() {
  const { publicKey, privateKey } = await generateKeyPair('RS256', {
    modulusLength: 2048  // 2048비트 RSA 키 (최소 권장)
  });

  // JWK 형식으로 내보내기 (저장 및 배포용)
  const publicJwk = await exportJWK(publicKey);
  const privateJwk = await exportJWK(privateKey);

  return { publicJwk, privateJwk };
}

// 인증 서버 - 개인키로 토큰 서명
async function signTokenWithPrivateKey(userId, privateJwk) {
  const privateKey = await importJWK(privateJwk, 'RS256');

  return await new SignJWT({ userId, role: 'admin' })
    .setProtectedHeader({ alg: 'RS256', kid: 'key-2024-01' })  // key ID 포함
    .setIssuer('https://auth.example.com')
    .setAudience('https://api.example.com')
    .setExpirationTime('1h')
    .sign(privateKey);
}

// 마이크로서비스 - 공개키로 토큰 검증
async function verifyTokenWithPublicKey(token, publicJwk) {
  const publicKey = await importJWK(publicJwk, 'RS256');

  const { payload } = await jwtVerify(token, publicKey, {
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com'
  });

  return payload;
}

// JWKS 엔드포인트 - 공개키 배포
app.get('/.well-known/jwks.json', (req, res) => {
  res.json({
    keys: [
      {
        ...publicJwk,
        kid: 'key-2024-01',
        use: 'sig',        // 서명용
        alg: 'RS256'
      }
    ]
  });
});

// 마이크로서비스에서 JWKS 자동 가져오기
import { createRemoteJWKSet } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));

app.use(async (req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '');

  try {
    // JWKS에서 kid를 보고 자동으로 적절한 공개키 선택
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: 'https://auth.example.com'
    });
    req.user = payload;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

설명

이것이 하는 일: RS256은 RSA 비대칭 암호화를 활용하여 토큰 발급 권한(개인키)과 검증 권한(공개키)을 분리함으로써, 많은 서비스가 토큰을 검증하면서도 새 토큰을 발급할 수 없게 만드는 보안 아키텍처입니다. 첫 번째로, generateRSAKeyPair 함수는 2048비트 RSA 키 쌍을 생성합니다.

이는 한 번만 실행하며, 생성된 키는 파일이나 AWS Secrets Manager 같은 안전한 저장소에 보관합니다. 2048비트는 현재 보안 표준이며, 더 강력한 보안이 필요하면 4096비트를 사용할 수 있습니다(하지만 서명/검증 속도가 느려짐).

exportJWK로 JWK(JSON Web Key) 형식으로 내보내는 이유는 이 형식이 JWT 생태계의 표준이어서 다양한 라이브러리와 호환되기 때문입니다. 공개키는 나중에 JWKS 엔드포인트를 통해 배포됩니다.

두 번째로, 인증 서버의 signTokenWithPrivateKey 함수는 개인키를 메모리로 불러와서 토큰을 서명합니다. 헤더에 kid(Key ID)를 포함시키는 것이 중요한데, 이는 나중에 키 로테이션 시 여러 공개키 중 어떤 것으로 검증해야 하는지 알려주기 때문입니다.

개인키는 절대 외부로 유출되어선 안 되며, 인증 서버의 메모리나 HSM(Hardware Security Module) 같은 안전한 환경에만 존재해야 합니다. 대칭키와 달리, 공개키를 가진 사람은 토큰을 검증만 할 수 있고 새로 만들 수는 없습니다.

세 번째로, 마이크로서비스들은 공개키만 가지고 있으면 됩니다. verifyTokenWithPublicKey 함수는 공개키로 토큰의 서명을 검증하는데, 이 과정에서 RSA 알고리즘의 수학적 특성을 활용합니다.

개인키로 서명된 데이터는 대응하는 공개키로만 검증 가능하므로, 검증이 성공하면 이 토큰이 정말 인증 서버에서 발급되었다는 것을 증명합니다. 더 진보된 방법은 createRemoteJWKSet을 사용하는 것인데, 이는 인증 서버의 /.well-known/jwks.json 엔드포인트에서 공개키를 자동으로 가져오고 캐싱합니다.

키 로테이션 시에도 자동으로 새 키를 가져오므로 마이크로서비스 코드를 변경할 필요가 없습니다. 여러분이 이 코드를 사용하면 제로 트러스트 아키텍처를 구현할 수 있습니다.

실무에서의 이점은 첫째, 마이크로서비스 침해 시 피해 제한(공개키만 유출되면 새 토큰 발급 불가), 둘째, 키 배포 간소화(공개키는 HTTP로 배포해도 안전), 셋째, 외부 파트너사에 API 제공 가능(공개키 공유로 토큰 검증 권한 부여), 넷째, 표준 준수(OpenID Connect, OAuth 2.0 등 모든 표준이 RS256 지원)입니다.

실전 팁

💡 개인키는 절대 소스 코드에 포함하지 말고, 환경변수도 피하세요. AWS Secrets Manager, HashiCorp Vault, 또는 최소한 암호화된 파일에 저장하고 런타임에만 메모리로 로드하세요. 💡 성능이 중요하다면 ES256(ECDSA with P-256)을 고려하세요. RS256보다 서명/검증이 빠르고 키 크기도 작지만, 같은 수준의 보안을 제공합니다. ES256은 특히 모바일 환경에서 유리합니다. 💡 JWKS 엔드포인트는 캐싱을 적용하세요. 예: Cache-Control: public, max-age=3600. 마이크로서비스가 매번 공개키를 가져오면 네트워크 오버헤드가 크므로, 1시간 정도 캐싱하는 것이 좋습니다. 💡 키 로테이션 계획을 세우세요. 추천 방식: 새 키를 생성하여 JWKS에 추가 → 새 토큰은 새 키로 서명 → 3-7일 후 구 키 제거. 이렇게 하면 무중단으로 키를 교체할 수 있습니다. 💡 개발 환경에서는 HS256, 프로덕션에서는 RS256을 사용하는 것도 좋은 전략입니다. 로컬 개발 시 키 관리 복잡도를 줄이고, 프로덕션에서만 엄격한 보안을 적용하는 식입니다. 단, 알고리즘 차이로 인한 버그를 방지하려면 스테이징 환경은 프로덕션과 동일하게 RS256을 사용하세요.


#JWT#Authentication#Security#TokenManagement#Encryption#JavaScript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.