Published on

OAuth2 PKCE+JWT에서 토큰 탈취 막는 7가지

Authors

서드파티 로그인이나 모바일 앱 인증을 OAuth2로 구현할 때, PKCE와 JWT를 적용했다고 해서 토큰 탈취가 자동으로 막히지는 않습니다. PKCE는 주로 Authorization Code 가로채기 공격을 줄이고, JWT는 토큰 자체의 무결성과 클레임을 표현하는 형식일 뿐입니다. 결국 탈취를 막는 핵심은 토큰이 흘러나갈 수 있는 경로를 닫고, 탈취되더라도 피해를 최소화하도록 수명을 짧게 하고, 재사용을 탐지하고, 바인딩과 회전을 설계하는 것입니다.

이 글에서는 OAuth2 Authorization Code with PKCE 흐름과 JWT 액세스 토큰을 전제로, 실무에서 바로 적용 가능한 7가지 방어책을 정리합니다.

기본 위협 모델: 토큰은 어디서 훔쳐지나

토큰 탈취는 대개 아래 경로에서 발생합니다.

  • 프론트엔드 저장소 노출: localStorage 같은 저장소, 디버그 로그, 크래시 리포트
  • 리다이렉트 URI/로그를 통한 코드·토큰 유출: 쿼리스트링, 리퍼러 헤더, 프록시 액세스 로그
  • 네트워크/프록시 구간의 평문 노출: TLS 미적용, 잘못된 프록시 설정
  • XSS로 인한 토큰 탈취
  • 탈취된 리프레시 토큰의 장기 악용
  • JWT 검증 미흡으로 인한 위조 토큰 수용

PKCE는 이 중 “코드 가로채기”에 강하지만, XSS나 저장소 노출, 리프레시 토큰 탈취에는 별도 대책이 필요합니다.

1) PKCE를 제대로 강제하고, S256만 허용

PKCE는 code_verifiercode_challenge를 통해 “코드를 훔쳐도 토큰 교환을 못 하게” 만듭니다. 하지만 서버가 PKCE를 선택 사항으로 두거나 plain을 허용하면 방어력이 급격히 떨어집니다.

핵심 체크리스트는 다음과 같습니다.

  • Authorization Server에서 PKCE 필수 강제
  • code_challenge_methodS256만 허용
  • code_verifier 길이와 문자셋 검증
  • Authorization Code는 1회성, 짧은 TTL, 재사용 방지

예시: code_verifierS256 챌린지 생성 (Node.js)

import crypto from 'crypto';

function base64url(buf) {
  return buf.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

export function createPkcePair() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(
    crypto.createHash('sha256').update(verifier).digest()
  );
  return { verifier, challenge, method: 'S256' };
}

서버 측에서는 토큰 엔드포인트에서 code_verifier를 반드시 요구하고, plain은 거절해야 합니다.

2) Authorization Code와 Redirect URI를 “정확히” 바인딩

코드를 탈취하지 못해도, 잘못된 리다이렉트 처리로 코드가 유출되는 경우가 많습니다. 특히 리다이렉트 URI를 와일드카드로 허용하거나, 동적 리다이렉트를 허용하면서 검증이 느슨하면 공격자가 자신의 도메인으로 코드를 유도할 수 있습니다.

권장 사항:

  • Redirect URI는 사전 등록된 값과 “문자열 완전 일치”로 비교
  • 쿼리 파라미터로 리다이렉트 URI를 받지 말 것
  • Authorization Code 발급 시점에 Redirect URI를 저장하고, 토큰 교환 시 동일 값인지 검증

예시: Redirect URI 완전 일치 검증 (의사 코드)

if request.redirect_uri not in client.registered_redirect_uris:
  reject

issue_code(code, redirect_uri=request.redirect_uri, client_id=...)

on token_exchange:
  if stored.redirect_uri != request.redirect_uri:
    reject

3) SPA는 액세스 토큰을 브라우저 저장소에 두지 말고 BFF 패턴 고려

JWT를 localStorage에 넣는 순간 XSS는 곧 토큰 탈취로 이어집니다. SPA가 토큰을 직접 들고 API를 호출하는 구조는 운영 편의성은 높지만, 클라이언트 보안에 모든 책임이 집중됩니다.

현실적으로 강력한 선택지는 BFF Backend For Frontend 입니다.

  • 브라우저는 HttpOnly, Secure, SameSite 쿠키로 “세션 쿠키”만 보관
  • BFF가 OAuth 토큰을 서버 측에 저장하고, API 호출을 프록시
  • 토큰은 서버 메모리나 암호화 저장소에만 존재

예시: BFF 쿠키 설정 (Express)

app.post('/session', (req, res) => {
  // 서버에 토큰을 저장했다고 가정
  res.cookie('sid', req.body.sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 1000,
  });
  res.status(204).end();
});

BFF를 쓰면 토큰이 브라우저 JS 실행 환경에 노출되지 않으므로, XSS가 발생해도 토큰 직접 탈취 난이도가 크게 올라갑니다.

4) 리프레시 토큰은 “회전”하고 재사용을 탐지

탈취 관점에서 가장 위험한 것은 수명이 긴 리프레시 토큰입니다. 액세스 토큰은 짧게 가져가도, 리프레시 토큰이 털리면 장기간 재발급이 가능합니다.

따라서 아래 3가지를 함께 적용해야 합니다.

  • Refresh Token Rotation: 새 리프레시 토큰 발급 시 기존 토큰 폐기
  • Reuse Detection: 폐기된 리프레시 토큰이 다시 오면 즉시 세션 전체 무효화
  • 토큰 패밀리 개념: 동일 로그인 세션에 속한 리프레시 토큰 체인을 추적

예시: 회전 및 재사용 탐지 로직 (의사 코드)

on refresh(token):
  record = db.find_refresh_token(token)
  if record is null: reject
  if record.revoked_at exists:
    revoke_family(record.family_id)
    reject

  new_token = mint_refresh_token(family_id=record.family_id)
  db.revoke(token)
  db.save(new_token)
  return new_access_token, new_token

이렇게 하면 리프레시 토큰이 유출되어도 “한 번” 이상 쓰는 순간 탐지되며, 피해 범위를 세션 단위로 묶어 차단할 수 있습니다.

5) JWT 수명은 짧게, 발급자·대상·키를 엄격히 검증

JWT는 흔히 “서버가 검증하니까 안전”이라고 오해하지만, 검증을 빼먹는 순간 위조 토큰이 통과합니다. 또한 수명이 길면 탈취 시 피해가 커집니다.

필수 검증 항목:

  • iss 발급자 일치
  • aud 대상 일치
  • exp 만료, nbf 유효 시점
  • 서명 알고리즘 고정 alg 화이트리스트
  • 키 식별자 kid 기반 JWK 조회 시 캐시와 회전 전략

운영 팁:

  • 액세스 토큰 TTL은 5분에서 15분 수준으로 짧게
  • 중요한 권한 변경 시 iat 이후 토큰만 허용하거나, 세션 버전 클레임을 둬서 강제 로그아웃 가능하게

예시: JWT 검증 (Node.js, jose)

import { jwtVerify, createRemoteJWKSet } from 'jose';

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

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: 'https://auth.example.com/',
    audience: 'api://payments',
    algorithms: ['RS256'],
  });
  return payload;
}

여기서 algorithms를 고정하지 않으면 알고리즘 혼동 공격에 취약해질 수 있습니다.

6) 토큰 바인딩 전략: DPoP 또는 mTLS로 “훔친 토큰”을 무력화

토큰이 탈취되는 것을 100퍼센트 막기 어렵다면, “탈취된 토큰이 다른 환경에서 재사용되지 않게” 만드는 것이 강력한 방어입니다.

대표 선택지:

  • DPoP Demonstration of Proof of Possession
    • 클라이언트가 매 요청마다 서명한 DPoP proof를 보내고, 서버는 토큰의 cnf 클레임과 매칭
    • 토큰을 훔쳐도 개인키가 없으면 재사용이 어려움
  • mTLS mutual TLS
    • 클라이언트 인증서를 바인딩하여 토큰을 특정 TLS 클라이언트에 묶음

예시: DPoP 헤더 구성 요소 (개념)

Authorization: DPoP access_token_value
DPoP: signed_jwt_proof

proof에는 htm=HTTP method, htu=URL, iat=timestamp, jti=nonce 등이 포함

브라우저 기반 퍼블릭 클라이언트에서는 DPoP 적용 난이도가 있지만, 모바일 앱이나 데스크톱 앱에서는 매우 효과적입니다.

7) 운영 레벨 차단: TLS, 헤더/로그 위생, 레이트 리밋과 이상징후 탐지

마지막은 구현보다 운영에서 새는 구멍을 막는 단계입니다. 토큰 유출 사고는 코드보다 “로그”에서 더 자주 터집니다.

필수 운영 수칙:

  • 전 구간 TLS 강제, HSTS 적용
  • 프록시 액세스 로그에서 Authorization 헤더 마스킹
  • 쿼리스트링에 코드나 토큰이 남지 않게 구성
  • 리다이렉트 후 Referer로 코드가 새지 않도록 Referrer-Policy 설정
  • 토큰 엔드포인트와 리프레시 엔드포인트에 레이트 리밋
  • 사용자 단위, IP 단위, 디바이스 지문 기반 이상징후 탐지

Kubernetes나 Ingress 환경에서는 프록시 버퍼, 바디 제한, 헤더 전달 정책 같은 설정이 인증 트래픽에 영향을 줄 수 있습니다. 운영 중 400, 413, 503 같은 에러가 나면 인증 실패가 “재시도 폭주”로 이어져 방어 로직이 무력화되기도 하니, 인프라 레벨 튜닝도 같이 점검하는 것이 좋습니다. 관련해서는 EKS NGINX Ingress 400·413 해결 - body·버퍼 튜닝 글이 도움이 됩니다.

또한 OIDC와 STS 연동에서의 신뢰 정책 문제는 토큰 검증과 유사한 성격의 장애를 만들 수 있습니다. 인증 체인의 “누가 누구를 신뢰하는가”를 점검하는 관점에서 EKS IRSA AccessDenied 해결 - OIDC·신뢰정책도 함께 참고할 만합니다.

정리: 7가지 방어책을 한 번에 체크리스트로

아래 7가지를 동시에 만족하면, PKCE와 JWT를 쓰는 환경에서 토큰 탈취 리스크를 실전 수준으로 낮출 수 있습니다.

  1. PKCE 강제, S256만 허용
  2. Authorization Code와 Redirect URI 완전 바인딩
  3. SPA는 토큰을 브라우저 저장소에 두지 말고 BFF 고려
  4. 리프레시 토큰 회전과 재사용 탐지 적용
  5. JWT TTL 단축, iss aud exp alg 등 엄격 검증
  6. DPoP 또는 mTLS로 토큰을 클라이언트에 바인딩
  7. TLS, 로그 마스킹, Referrer 정책, 레이트 리밋, 이상징후 탐지까지 운영 방어

PKCE와 JWT는 “퍼즐 조각”이고, 탈취 방지는 전체 조립의 결과입니다. 특히 리프레시 토큰 회전, BFF, DPoP는 구현 난이도가 있더라도 한 번 도입하면 사고 확률을 크게 낮추는 투자 대비 효과가 큽니다.