JWT는 서명이 아니라 검증에서 무너진다

JWT를 base64_encode와 hash_hmac으로 직접 조립하지 마십시오. 토큰을 만드는 쪽은 단순하지만, 받은 토큰을 믿을지 판단하는 쪽에 함정이 몰려 있습니다.

올해 AIvance 제품들의 인증을 세 번 다시 설계했습니다. 처음은 공용 기반인 ci4-board였습니다. firebase/php-jwt를 쓰고 Access/Refresh를 분리했지만, HS256 대칭키에 static 서비스, 닉네임·이메일·그룹명까지 실은 무거운 토큰이었습니다. 다음 AITessera(토큰 인증 API)에서는 RSA/ECDSA 서명과 인증 엔드포인트 레이트리밋을 넣었습니다. 세 번째 웹 채팅 상담 솔루션에서는 CI4가 개인키로 서명하고 Node 엔진은 공개키로 검증만 하게 나눴습니다.

세 번을 거치며 남은 원칙은 네 가지입니다.

  1. 서명과 검증은 검증된 라이브러리에 맡기고, 알고리즘은 서버가 정한다.
  2. Access는 짧고 가볍게, Refresh는 길되 서버가 통제한다.
  3. Refresh는 쓸 때마다 회전하고, 이미 쓴 토큰이 다시 오면 탈취로 본다.
  4. 즉시 무효화가 필요한 경우만 Redis 블랙리스트로 막는다.

alg:none 우회: 헤더를 믿는 순간 끝난다

JWT 헤더의 alg는 공격자가 마음대로 쓸 수 있는 값입니다. 검증 코드가 이 값을 보고 알고리즘을 고르면, 서명 검증의 주도권이 공격자에게 넘어갑니다.

직접 만든 검증기는 대개 이런 모양이 됩니다.

// 하지 말 것 — 알고리즘을 토큰에게 묻는다
[$h, $p, $s] = explode('.', $token);
$header = json_decode(base64_decode($h), true);   // base64url이 아닌 base64

$algo = ['HS256' => 'sha256', 'HS512' => 'sha512'][$header['alg']] ?? null;
if ($algo === null) {
    return json_decode(base64_decode($p));         // "none"이면 통과시켜 버린다
}

$expected = hash_hmac($algo, "$h.$p", $secret, true);
if ($expected == base64_decode($s)) {              // 타이밍 공격에 열린 비교
    return json_decode(base64_decode($p));
}

이 열 줄에 구멍이 네 개 있습니다.

  • alg:none: 서명 없는 토큰을 그대로 받아들입니다. 페이로드에 "sub":"1"만 넣으면 관리자가 됩니다.
  • 알고리즘 혼동: RS256을 쓰는 서버가 헤더를 믿으면, 공격자는 alg를 HS256으로 바꾸고 공개키를 HMAC 비밀키로 써서 서명합니다. 공개키는 말 그대로 공개돼 있습니다.
  • base64url: JWT는 -, _를 쓰는 base64url입니다. base64_decode는 일부 토큰을 조용히 잘못 읽습니다.
  • == 비교: 문자열 비교 시간이 달라 서명을 한 바이트씩 맞혀 볼 여지를 줍니다. hash_equals가 필요합니다.

firebase/php-jwt는 v6부터 이 문제를 구조로 막습니다. Key 객체가 키와 알고리즘을 묶어서, 헤더가 무엇을 주장하든 서버가 정한 알고리즘으로만 검증합니다.

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

// 알고리즘은 서버가 고정한다. 헤더의 alg가 다르면 예외.
$claims = JWT::decode($token, new Key($publicKey, 'RS256'));

// exp·nbf·iat는 라이브러리가 본다. iss·aud는 직접 확인해야 한다.
if ($claims->iss !== 'aivance-auth' || $claims->aud !== 'aivance-api') {
    throw new TokenInvalidException();
}

라이브러리를 써도 iss와 aud 확인은 여전히 내 몫입니다. 웹 채팅 솔루션에서는 백오피스용과 채팅 엔진용 토큰의 aud를 나눴습니다. 같은 키로 서명했더라도 한쪽 토큰을 다른 쪽에 들이밀 수 없게 하려는 것입니다.

Access/Refresh 분리: 짧은 것은 믿고, 긴 것은 통제한다

Access 토큰은 서버가 상태 없이 검증하는 대신 수명을 15분으로 짧게 잡습니다. Refresh 토큰은 7일을 살지만 서버 저장소에 있어야만 유효합니다. 제 CI4 프로젝트들의 CLAUDE.md에 기본값으로 박아 둔 숫자입니다.

구분

Access 토큰

Refresh 토큰

수명

15분

7일

형식

서명된 JWT

무작위 256비트 문자열 (JWT 아님)

전달

Authorization: Bearer 헤더

HttpOnly; Secure; SameSite=Strict 쿠키, Path=/api/v1/auth

검증

공개키 서명 + 블랙리스트 조회

Redis에 해시가 있어야 유효

클레임

sub, jti, tv, iss, aud, exp

없음

무효화

만료를 기다리거나 블랙리스트

키 삭제 즉시

이 표에는 ci4-board 코드 리뷰에서 지적받은 세 가지가 반영돼 있습니다.

첫째, 클레임을 줄였습니다. ci4-board의 Access 토큰에는 닉네임, 이메일, 그룹명이 들어 있었습니다. 사용자가 닉네임을 바꾸면 토큰이 만료될 때까지 옛 값이 돌아다닙니다. 권한 그룹이 바뀌면 더 위험합니다. 지금은 sub(사용자 ID)와 식별용 jti, 무효화용 tv(토큰 버전)만 싣고, 프로필은 API로 조회합니다.

둘째, HS256을 RS256으로 바꿨습니다. 모놀리스에서는 HS256으로 충분합니다. 하지만 서비스가 나뉘면 검증하는 모든 서비스가 서명 비밀키를 나눠 가져야 합니다. 그중 하나만 털려도 아무 토큰이나 위조할 수 있습니다. 웹 채팅 솔루션에서는 CI4 백오피스만 개인키를 갖고, Node 채팅 엔진에는 공개키만 배포했습니다. 엔진이 털려도 토큰을 만들 수는 없습니다.

셋째, Refresh 토큰을 JWT로 만들지 않았습니다. Refresh는 어차피 서버 저장소를 조회해야 합니다. 서명된 JWT로 만들면 "서명이 맞으니 유효하다"는 착각을 부르고, 내용이 노출될 이유도 없습니다. 무작위 문자열을 주고 서버에는 SHA-256 해시만 저장합니다.

Refresh 엔드포인트는 공격자가 가장 많이 두드리는 곳이기도 합니다. AITessera에서는 토큰 발급·갱신·가입 엔드포인트를 IP 기준 분당 10회로 묶고, 일반 API는 분당 120회로 뒀습니다. 넘으면 429와 Retry-After를 돌려줍니다.

Redis 회전·블랙리스트: 쓴 토큰이 다시 오면 탈취다

Refresh 토큰은 한 번 쓰면 버리고 새것을 줍니다. 이미 버린 토큰이 다시 들어오면 누군가 복사해 간 것이므로, 그 로그인에서 나온 토큰 전체(패밀리)를 폐기합니다.

ci4-board에서는 Refresh 토큰을 MySQL 테이블에 revoked, expires_at, client_ip와 함께 저장했습니다. 폐기는 됐지만 요청마다 DB를 치고, 만료된 행을 지우는 배치가 따로 필요했습니다. 만료가 곧 TTL인 데이터라 Redis로 옮겼습니다.

키 설계

키

값

TTL

용도

rt:{토큰해시}

{familyId}|{userId}

7일

제출된 Refresh가 우리가 발급한 것인지

rf:{familyId}

현재 유효한 토큰해시

7일

패밀리의 "최신" 포인터

bl:{jti}

1

Access 남은 수명

로그아웃한 Access 즉시 차단

uv:{userId}

정수

없음

비밀번호 변경·탈취 시 사용자 토큰 일괄 무효화

핵심은 회전 후에도 옛 rt: 키를 바로 지우지 않는 것입니다. 지워 버리면 옛 토큰이 다시 왔을 때 "모르는 토큰"과 "이미 쓴 토큰"을 구분할 수 없습니다. 재사용 탐지는 rf: 포인터와 비교해서 합니다.

회전은 원자적으로

조회, 비교, 갱신 사이에 다른 요청이 끼면 같은 토큰으로 두 번 회전할 수 있습니다. 그래서 한 번의 Lua 스크립트로 처리합니다.

-- KEYS[1] = rt:{제출된 해시}
-- ARGV[1] = 제출된 해시, ARGV[2] = 새 해시, ARGV[3] = TTL(초)
local v = redis.call('GET', KEYS[1])
if not v then
  return {'invalid'}                      -- 발급한 적 없거나 만료
end

local fam = string.match(v, '^([^|]+)')
local uid = string.match(v, '|(.+)$')
local cur = redis.call('GET', 'rf:' .. fam)

if cur ~= ARGV[1] then
  redis.call('DEL', 'rf:' .. fam)         -- 이미 회전된 토큰 재사용 → 패밀리 폐기
  redis.call('INCR', 'uv:' .. uid)        -- 살아 있는 Access도 무효화
  return {'reuse', uid}
end

redis.call('SET', 'rt:' .. ARGV[2], v, 'EX', ARGV[3])
redis.call('SET', 'rf:' .. fam, ARGV[2], 'EX', ARGV[3])
return {'ok', uid}

rf:가 지워진 패밀리의 토큰은 이후 모두 reuse로 떨어집니다. 공격자도, 정상 사용자도 재로그인해야 합니다. 정상 사용자에게는 불편하지만, 누가 진짜인지 서버가 알 수 없는 상황에서는 이 편이 맞습니다.

스크립트가 KEYS에 없는 키를 만지므로 단일 Redis(또는 클러스터 모드가 아닌 ElastiCache) 기준입니다. 클러스터라면 토큰 문자열 앞에 familyId를 붙여 발급하고, rt:{fam}:해시처럼 해시태그로 같은 슬롯에 모아야 합니다.

블랙리스트는 작게

Access 토큰을 전부 Redis에 적으면 JWT를 쓸 이유가 사라집니다. 블랙리스트에는 로그아웃처럼 "지금 당장" 막아야 하는 토큰의 jti만, 그 토큰의 남은 수명만큼 넣습니다. 15분이 지나면 키가 저절로 사라지므로 블랙리스트가 무한히 커지지 않습니다.

비밀번호 변경이나 탈취 탐지처럼 사용자의 모든 토큰을 끊어야 할 때는 jti를 하나하나 적지 않습니다. uv:{userId}를 1 올리면, 토큰의 tv와 맞지 않는 Access는 전부 거절됩니다.

아래 그림은 Refresh 요청 하나가 거치는 판단입니다.

모르는 토큰은 단순 거절, 이미 쓴 토큰은 탈취로 처리한다는 차이가 이 설계의 전부입니다.

CI4 구현: static을 버리고 Services와 Filter로

검증 로직은 서비스 하나에 모으고, 컨트롤러 앞단은 Filter가 지킵니다. ci4-board의 JwtService는 전부 static이었고 현재 사용자를 private static으로 들고 있었습니다. 테스트에서 키나 Redis를 바꿔 끼우기 어려워 Config\Services에 등록하는 인스턴스 방식으로 바꿨습니다.

// app/Config/Services.php
public static function tokens(bool $getShared = true): \App\Services\TokenService
{
    if ($getShared) {
        return static::getSharedInstance('tokens');
    }

    $redis = new \Redis();
    $redis->connect(env('redis.host', '127.0.0.1'), (int) env('redis.port', 6379));

    return new \App\Services\TokenService(
        privateKey: file_get_contents(env('jwt.privateKeyPath')),
        publicKey:  file_get_contents(env('jwt.publicKeyPath')),
        redis:      $redis,
        issuer:     'aivance-auth',
        audience:   'aivance-api',
    );
}

키 파일은 writable/keys/ 아래에 두고 .gitignore에 넣습니다. 운영에서는 AWS Secrets Manager나 SSM Parameter Store에서 배포 시 주입합니다.

// app/Services/TokenService.php
final class TokenService
{
    private const ALG = 'RS256';
    private const ACCESS_TTL = 900;            // 15분

    public function __construct(
        private string $privateKey,
        private string $publicKey,
        private \Redis $redis,
        private string $issuer,
        private string $audience,
    ) {}

    public function issueAccess(int $userId): string
    {
        $now = time();

        return JWT::encode([
            'iss' => $this->issuer,
            'aud' => $this->audience,
            'sub' => (string) $userId,
            'iat' => $now,
            'exp' => $now + self::ACCESS_TTL,
            'jti' => bin2hex(random_bytes(16)),
            'tv'  => (int) ($this->redis->get("uv:{$userId}") ?: 0),
        ], $this->privateKey, self::ALG);
    }

    public function verifyAccess(string $jwt): \stdClass
    {
        $c = JWT::decode($jwt, new Key($this->publicKey, self::ALG));

        if ($c->iss !== $this->issuer || $c->aud !== $this->audience) {
            throw new TokenInvalidException();
        }
        if ($this->redis->exists("bl:{$c->jti}")) {
            throw new TokenRevokedException();
        }
        if ((int) ($this->redis->get("uv:{$c->sub}") ?: 0) !== $c->tv) {
            throw new TokenRevokedException();
        }

        return $c;
    }

    public function revokeAccess(\stdClass $c): void
    {
        $ttl = max(1, $c->exp - time());
        $this->redis->setex("bl:{$c->jti}", $ttl, '1');
    }
}

Filter는 토큰을 꺼내 검증하고, 만료와 그 밖의 실패를 구분해 돌려줍니다. 클라이언트는 token.expired일 때만 Refresh를 시도하고, 나머지는 곧장 로그인 화면으로 보냅니다.

// app/Filters/JwtAuth.php
final class JwtAuth implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        if (! preg_match('/^Bearer\s+(\S+)$/', $request->getHeaderLine('Authorization'), $m)) {
            return $this->deny('token.missing');
        }

        try {
            $claims = service('tokens')->verifyAccess($m[1]);
        } catch (ExpiredException) {
            return $this->deny('token.expired');
        } catch (\UnexpectedValueException | \DomainException | TokenException) {
            return $this->deny('token.invalid');   // 서명 불일치, alg 불일치, 폐기 모두 여기
        }

        service('authContext')->set($claims);       // 요청 범위의 현재 사용자
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}

    private function deny(string $code): ResponseInterface
    {
        return service('response')
            ->setStatusCode(401)
            ->setJSON(['status' => 401, 'error' => $code]);
    }
}

ExpiredException, SignatureInvalidException, BeforeValidException은 모두 UnexpectedValueException을 상속합니다. 그래서 만료를 먼저 잡고 나머지를 한 줄로 받습니다. 어떤 이유로 거절됐는지는 로그에만 남기고, 응답에는 "유효하지 않다"고만 씁니다.

Refresh 컨트롤러는 쿠키의 토큰을 SHA-256으로 해시해 앞의 Lua 스크립트에 넘기고, ok일 때만 새 Access와 새 Refresh 쿠키를 내려줍니다. reuse가 나오면 보안 로그에 사용자 ID와 IP를 남기고 401을 돌려줍니다.

체크리스트: 테스트로 못 박아야 할 것들

원칙은 문서보다 테스트에 있어야 오래갑니다. 특히 AI와 함께 코드를 고치면 "정리"라는 이름으로 검증 한 줄이 빠지는 일이 생깁니다. 아래 항목은 전부 실패해야 통과하는 테스트로 둡니다.

  • alg:none으로 만든 토큰을 거절한다
  • 헤더를 HS256으로 바꾸고 공개키로 서명한 토큰을 거절한다
  • iss나 aud가 다른 토큰을 거절한다
  • 만료된 토큰은 token.expired, 위조 토큰은 token.invalid로 구분된다
  • 로그아웃한 Access 토큰이 만료 전에도 거절된다
  • 한 번 회전한 Refresh 토큰을 다시 쓰면 패밀리 전체가 폐기된다
  • 재사용 탐지 뒤에는 같은 사용자의 살아 있던 Access도 거절된다
  • Refresh 엔드포인트가 분당 10회를 넘으면 429를 돌려준다
  • 저장소와 로그 어디에도 Refresh 토큰 원문이 남지 않는다

JWT 자체는 어렵지 않습니다. 어려운 것은 "이 토큰을 믿어도 되는가"를 매 요청 정확히 판단하는 일이고, 그 판단에는 이미 많은 사람이 밟고 지나간 함정이 깔려 있습니다. 서명은 라이브러리에, 알고리즘은 서버에, 무효화는 Redis에 맡기고, 내 코드는 그 셋을 올바른 순서로 부르는 데만 쓰는 것이 26년 동안 배운 가장 싼 보안입니다.