Published on

Cloudflare Zero Trust로 OAuth JWT 세션하이재킹 차단

Authors

서버에서 JWT를 잘 서명하고 만료를 짧게 잡아도, 결국 문제는 토큰이 탈취되면 끝이라는 점입니다. 특히 SPA나 모바일 앱에서 Authorization: Bearer ... 형태로 액세스 토큰을 쓰거나, 백엔드가 세션 대신 JWT를 쿠키로 운용할 때, XSS·악성 확장프로그램·프록시 로그·오픈 리다이렉트·디바이스 탈취 등으로 토큰이 새어 나가면 재사용(replay)이 가능합니다.

이 글에서는 Cloudflare Zero Trust를 이용해 OAuth JWT 세션 하이재킹을 “완벽히 불가능”하게 만들기보다, 재사용 비용을 급격히 올리고 탐지·차단을 자동화하는 실전 구성을 다룹니다. 핵심은 다음 3겹입니다.

  • 엣지에서 인증 컨텍스트를 강제: Cloudflare Access로 “누가, 어떤 디바이스로, 어떤 IdP 세션으로” 접근하는지 고정
  • 요청 수준 방어: WAF/Rate Limit/Bot으로 토큰 재사용 패턴과 자동화를 차단
  • 앱 수준 보강: 토큰을 브라우저에 덜 노출하고(쿠키/세션), 토큰 바인딩에 가까운 제약을 추가

관련해서 Spring 기반 OAuth/JWT에서 401이 발생하는 서명키 이슈를 먼저 정리하고 싶다면 이 글도 함께 보세요: Spring Security OAuth2 JWT 서명키 불일치 401 해결

1) 위협 모델: “JWT 세션하이재킹”이란 무엇인가

여기서 말하는 세션 하이재킹은 전통적인 세션 쿠키 탈취뿐 아니라, JWT 액세스 토큰 탈취 후 재사용까지 포함합니다.

대표 시나리오:

  1. 공격자가 XSS로 localStorage/메모리/JS 변수에 있는 토큰을 탈취
  2. 프론트가 실수로 토큰을 URL 쿼리나 로그에 남김
  3. 프록시/관측 도구가 Authorization 헤더를 수집
  4. 사용자 디바이스가 악성 확장프로그램에 감염
  5. 탈취된 토큰을 다른 IP/국가/브라우저에서 재사용

JWT는 기본적으로 “소지자(bearer) 토큰”이라 들고 있으면 통과합니다. 그래서 방어는 보통 아래 중 하나를 섞습니다.

  • 토큰 수명 짧게 + 리프레시 토큰 회전
  • 토큰을 브라우저에 덜 노출(HTTP-only 쿠키)
  • 요청 컨텍스트(IP, 디바이스, mTLS, DPoP 등)와 바인딩
  • 엣지에서 리스크 기반 차단(국가, ASN, 봇, 속도)

Cloudflare Zero Trust는 이 중 “엣지에서 컨텍스트를 강제하고, 재사용을 탐지·차단”하는 데 강합니다.

2) Cloudflare Zero Trust에서 쓸 수 있는 무기들

Cloudflare 제품명이 헷갈리기 쉬워서, 이 글에서 사용할 컴포넌트를 역할로 정리합니다.

2.1 Cloudflare Access (Zero Trust)로 앱 앞단 인증 게이트 세우기

Access는 애플리케이션 앞에 SSO를 강제하는 프록시 레이어입니다.

  • IdP 연동: Google Workspace, Okta, Azure AD, GitHub 등
  • 정책: 이메일 도메인, 그룹, 디바이스 포스처(WARP), MFA 등
  • 헤더 주입: Cf-Access-Jwt-Assertion 같은 Access 토큰을 원본(origin)에 전달

즉, 앱이 가진 OAuth/JWT와 별개로 **“엣지에서 한 번 더 로그인/정책 검증”**을 합니다.

2.2 WAF / Rate Limiting / Bot Management

JWT가 탈취되면 공격자는 보통 자동화된 방식으로 재사용을 시도합니다.

  • WAF 커스텀 룰로 특정 경로, 헤더, 국가, ASN, UA 조합 차단
  • Rate limit로 토큰 재사용 시도나 크리덴셜 스터핑 완화
  • Bot 관리로 헤드리스/스크립트 기반 트래픽 식별

2.3 Gateway (Secure Web Gateway)와 WARP

조직 내부 앱이라면, “사내에서만 접근” 같은 요구가 많습니다.

  • WARP 클라이언트를 설치한 디바이스만 접근 허용
  • 디바이스 포스처(암호화, OS 버전, MDM 등록 등) 기반 정책

이 조합은 “토큰만 훔쳐도 사내 디바이스가 아니면 접근 불가”에 가깝게 만듭니다.

3) 권장 아키텍처: 앱 OAuth와 Access를 겹쳐라

가장 실용적인 패턴은 “앱의 OAuth 인증”과 “Cloudflare Access 인증”을 직렬로 겹치는 방식입니다.

  • 1차 게이트: Cloudflare Access 정책 통과
  • 2차 게이트: 앱 자체 OAuth/JWT 검증

이렇게 하면 JWT가 탈취되어도, 공격자는 추가로 Access 정책(예: WARP 디바이스, 그룹, MFA)을 통과해야 합니다.

3.1 트래픽 흐름

  1. 사용자가 https://app.example.com 접속
  2. Cloudflare Access가 IdP 로그인 요구
  3. 통과 시 Cloudflare가 원본으로 프록시하면서 Cf-Access-Jwt-Assertion 헤더를 주입
  4. 앱은 기존대로 OAuth 세션을 처리하되, 추가로 Access JWT를 검증

앱에서 “Access JWT 검증”은 생각보다 단순합니다. 공개키(JWKS)로 서명 검증하고, aud, iss, 만료 등을 체크하면 됩니다.

4) 실전 구성 1: Cloudflare Access 애플리케이션 설정

Cloudflare Zero Trust 대시보드에서 Access 애플리케이션을 만듭니다.

  • Application type: Self-hosted
  • Domain: app.example.com
  • Policies 예시
    • Allow: email domain is example.com
    • Require: MFA
    • Require: WARP (선택)

여기서 중요한 포인트는 정책을 “토큰 재사용을 어렵게 만드는 방향”으로 잡는 것입니다.

  • 외부 공개 서비스라면 WARP 강제는 어렵지만, 최소한 그룹/MFA를 강제
  • 내부 어드민/백오피스는 WARP + 디바이스 포스처까지 강제

5) 실전 구성 2: 원본에서 Cf-Access-Jwt-Assertion 검증

Access를 켰다고 끝이 아닙니다. “엣지가 통과시켰으니 원본은 믿자”로 가면, 우회 경로(원본 직접 접근, 다른 프록시 경로, 잘못된 DNS)에서 깨질 수 있습니다.

따라서 원본 애플리케이션은 다음을 권장합니다.

  • 원본은 Cloudflare IP만 허용(방화벽)하거나, 터널(Cloudflare Tunnel) 사용
  • 앱에서 Cf-Access-Jwt-Assertion을 검증하고, 없으면 거부

5.1 Node.js(Express) 예제

아래 코드는 Access JWT를 RS256으로 검증하는 최소 예시입니다. 환경에 맞게 AUDTEAM_DOMAIN을 설정하세요.

import express from 'express'
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'

const app = express()

const TEAM_DOMAIN = process.env.CF_TEAM_DOMAIN // 예: "myteam.cloudflareaccess.com"
const AUD = process.env.CF_ACCESS_AUD          // Access App의 Audience

const client = jwksClient({
  jwksUri: `https://${TEAM_DOMAIN}/cdn-cgi/access/certs`
})

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err)
    callback(null, key.getPublicKey())
  })
}

function requireAccess(req, res, next) {
  const token = req.header('Cf-Access-Jwt-Assertion')
  if (!token) return res.status(401).send('Missing Access token')

  jwt.verify(
    token,
    getKey,
    {
      algorithms: ['RS256'],
      audience: AUD,
      issuer: `https://${TEAM_DOMAIN}`
    },
    (err, decoded) => {
      if (err) return res.status(401).send('Invalid Access token')
      req.access = decoded
      next()
    }
  )
}

app.get('/api/admin', requireAccess, (req, res) => {
  res.json({ ok: true, email: req.access.email })
})

app.listen(3000)

이 레이어를 넣으면 JWT가 탈취되어도, 공격자는 “Access 토큰까지” 가져와야 하고, Access 정책(MFA, WARP 등)을 동시에 만족해야 합니다.

6) JWT 세션하이재킹을 줄이는 앱 레벨 설계

Cloudflare만으로 모든 앱 내부 위험을 해결할 수는 없습니다. 특히 XSS는 “Access 세션까지” 훔칠 수 있습니다. 그래서 앱 레벨에서 토큰 노출 면적을 줄여야 합니다.

6.1 브라우저에는 액세스 토큰을 두지 말고, BFF 패턴 고려

SPA에서 localStorage 토큰은 XSS에 취약합니다. 가능하면 다음을 고려하세요.

  • Backend for Frontend(BFF): 프론트는 서버에만 요청
  • 서버는 HTTP-only 쿠키 기반 세션 사용
  • 서버가 OAuth 토큰을 보관하고, 프론트로 노출하지 않음

Next.js를 쓰는 경우, RSC/서버 액션과 함께 BFF 형태가 자연스럽습니다. 다만 서버 fetch 캐시가 인증 컨텍스트와 섞이면 사고가 나므로, 캐시 전략을 점검해야 합니다. 관련 글: Next.js 14 RSC에서 fetch 캐시 꼬임 해결법

6.2 리프레시 토큰 회전과 재사용 감지

JWT 액세스 토큰 만료를 짧게(예: 5~15분) 하고, 리프레시 토큰은 회전(rotate) + 재사용 감지(reuse detection)를 켜면, 탈취된 액세스 토큰의 유효 시간이 짧아집니다.

  • 리프레시 토큰이 재사용되면 전체 세션 폐기
  • 디바이스별 세션 목록 제공 및 강제 로그아웃

6.3 토큰 바인딩에 가까운 제약: 컨텍스트 클레임

완전한 토큰 바인딩(DPoP, mTLS)은 앱/클라이언트 구현 부담이 큽니다. 현실적인 대안은 “컨텍스트를 클레임으로 넣고 서버에서 검증”하는 것입니다.

예:

  • cnf 유사 클레임에 디바이스 키 지문
  • 최초 로그인 시점의 위험 신호(국가, ASN)를 세션 메타로 저장
  • 이후 요청이 크게 달라지면 재인증 요구

Cloudflare는 ASN/국가/봇 점수 같은 신호를 엣지에서 잘 잡아주므로, 앱은 “바뀌면 재인증” 같은 정책을 만들기 쉽습니다.

7) 실전 구성 3: WAF 룰로 토큰 재사용 패턴을 자르기

토큰 재사용 공격은 종종 다음 특징이 있습니다.

  • 짧은 시간에 여러 IP에서 동일 계정/세션으로 호출
  • 특정 API에 반복적으로 401/403을 유발
  • 헤드리스 UA, 비정상적인 TLS 지문

Cloudflare WAF 커스텀 룰에서 다음 신호를 조합합니다.

  • 경로: /api/ /oauth/ /admin/
  • 국가 차단(업무용 서비스라면 효과 큼)
  • ASN 차단(악성 호스팅 대역)
  • Bot score 기반 차단 또는 챌린지

정교한 룰은 조직마다 다르지만, 예를 들어 “관리자 API는 한국에서만, Bot score 낮으면 차단” 같은 식으로 시작해도 재사용 공격의 성공률을 크게 낮춥니다.

8) 원본 직접 접근 차단: 터널 또는 방화벽 allowlist

Access를 붙여도 원본이 인터넷에 그대로 노출되어 있으면, 공격자는 origin IP를 찾아 직접 때릴 수 있습니다.

권장 우선순위:

  1. Cloudflare Tunnel로 원본을 비공개 네트워크로 숨김
  2. 최소한 원본 방화벽에서 Cloudflare IP만 허용

이 조치가 없으면 “Access는 웹 UI만 보호하고 API는 우회 가능” 같은 구멍이 생깁니다.

9) 운영 관점: 탐지, 로깅, 사고 대응 플로우

차단만큼 중요한 게 “언제 털렸는지 아는 것”입니다.

  • Cloudflare 로그(Access, WAF, Gateway)를 SIEM으로 적재
  • 다음 이벤트를 알림
    • 동일 사용자에 대한 다국가 로그인
    • 짧은 시간 내 401 급증
    • 특정 토큰/세션으로 IP가 급변

앱에서도 다음을 남기면 좋습니다.

  • sub(user id), jti(token id), sid(session id) 기반 요청 추적
  • refresh token 회전 실패, 재사용 감지 이벤트

10) 자주 하는 실수와 체크리스트

10.1 실수: Access만 믿고 앱은 헤더 검증을 안 함

  • 해결: Cf-Access-Jwt-Assertion이 없으면 401, 서명/aud/iss 검증

10.2 실수: 원본이 공개되어 Access 우회 가능

  • 해결: Tunnel 또는 원본 방화벽 제한

10.3 실수: SPA에서 토큰을 localStorage에 저장

  • 해결: BFF + HTTP-only 쿠키, 또는 최소한 메모리 저장 + 강한 CSP

10.4 실수: JWT 만료가 너무 김

  • 해결: 액세스 토큰 짧게, 리프레시 토큰 회전, 세션 무효화 경로 마련

11) 결론: “토큰 탈취”를 전제로, 엣지에서 재사용을 무력화하라

OAuth JWT 기반 인증의 약점은 구현 실수보다도 “토큰이 곧 권한”이라는 성질입니다. Cloudflare Zero Trust는 이를 보완하는 좋은 실전 도구입니다.

  • Access로 IdP/MFA/디바이스 포스처를 강제해 토큰만으로는 부족하게 만들고
  • WAF/Bot/Rate limit로 재사용 자동화를 엣지에서 소거하며
  • 앱에서는 BFF, 짧은 만료, 회전, 재인증 정책으로 노출 면적을 축소하세요.

이 3겹이 갖춰지면, JWT가 유출되더라도 “바로 계정이 털리는 사고”에서 “탐지되고 차단되는 시도”로 성격이 바뀝니다.