인증 시스템은 왜 이렇게 자주 뚫리는가
JWT alg:none부터 OAuth2 PKCE, 세션 고정, CSRF, 브루트포스, 비밀번호 해시까지 — 인증 취약점 6가지가 공유하는 하나의 설계 실수를 추적한다.
- 01 보안 설정을 켰다고 안전한 게 아니다
- 02 인젝션 취약점의 공통 구조: 파서를 속이는 법과 막는 법
- 03 인증 시스템은 왜 이렇게 자주 뚫리는가
- 04 XSS부터 Clickjacking까지, 웹 보안 헤더의 설계 철학
- 05 API 보안의 근본은 하나다 — '신뢰하지 말고 검증하라'
- 06 서버가 공격자의 손이 되는 순간 — Spring 보안 설계의 5가지 원칙
- 07 보안은 도구가 아니라 파이프라인이다
JWT, 세션, OAuth2, 비밀번호 저장 — 인증 시스템을 구성하는 요소들은 각자 다른 기술처럼 보인다. 그러나 실제 침해 사고를 들여다보면 반복되는 패턴이 있다. 공격자가 성공하는 이유는 언제나 서버가 클라이언트가 제시한 값을 검증하지 않았기 때문이다. 이 일관된 실수는 어디서 어떻게 나타나는가?
검증하지 않으면 무엇이든 통과한다 — JWT의 경우
JWT는 세 부분으로 구성된다: header.payload.signature. 서버가 서명을 검증하면 페이로드의 위·변조를 막을 수 있다. 문제는 이 검증을 제대로 하지 않을 때 시작된다.
alg:none 공격은 헤더의 알고리즘 필드를 none으로 설정하고 서명을 빈 문자열로 만드는 방식이다. 서버가 알고리즘을 명시적으로 검증하지 않으면 서명 없이 임의의 페이로드가 통과한다.
// ❌ JWT.decode()만 수행 — 서명 검증 없음
DecodedJWT decodedJWT = JWT.decode(token);
String userId = decodedJWT.getSubject(); // attacker가 넣은 값 그대로
Algorithm Confusion 공격은 더 정교하다. RS256(비대칭)으로 발급된 토큰의 알고리즘을 HS256(대칭)으로 바꾸고, 서버의 공개키를 HMAC 비밀키로 사용해 서명을 만든다. 공개키는 /.well-known/jwks.json으로 누구나 다운로드할 수 있다. 서버가 “알고리즘은 클라이언트가 정하는 것”으로 신뢰하면 공격이 성립한다.
kid(Key ID) 헤더 인젝션도 같은 구조다. 서버가 kid 값을 그대로 키 저장소 조회에 사용하면, 공격자는 경로 조작(../../etc/passwd)이나 임의 문자열로 자신이 원하는 키가 사용되게 만들 수 있다.
세 가지 공격의 공통점: 서버가 클라이언트 입력(alg, kid)을 신뢰했다.
// ✅ 화이트리스트로 검증
if (!"RS256".equals(algorithm)) throw new SecurityException("RS256만 허용");
if (!ALLOWED_KEYS.containsKey(kid)) throw new SecurityException("허용되지 않는 kid");
if (!Pattern.matches("^[a-zA-Z0-9_-]+$", kid)) throw new SecurityException("형식 오류");
만료와 무효화 — JWT가 상태 없음의 대가를 치르는 방식
JWT가 Stateless라는 사실은 장점이자 약점이다. 한번 발급된 토큰은 서버가 “기억”하지 않는다. 그러므로 로그아웃해도 토큰은 만료될 때까지 유효하다.
Access Token은 15분, Refresh Token은 7일로 분리하고, Refresh Token은 Redis에 저장해 무효화를 가능하게 만드는 것이 현실적 타협점이다. 로그아웃 시에는 jti(JWT ID)를 블랙리스트에 추가하고 TTL을 토큰 만료 시간과 동일하게 설정한다. 만료된 토큰의 jti는 Redis에서 자동 삭제되므로 저장소가 무한히 늘지 않는다.
Stateless를 완전히 유지하려면 짧은 만료 시간이 유일한 방어선이다. Redis 블랙리스트를 도입하는 순간 Stateful 요소가 생기지만, 로그아웃 보장이라는 실용적 이득을 얻는다. 둘 중 하나를 선택하는 것이 아니라, 어디서 Stateful을 허용할지 의식적으로 결정하는 문제다.
세션 고정과 CSRF — 브라우저가 쿠키를 자동으로 보낸다는 사실의 역이용
세션 고정(Session Fixation) 공격은 로그인 전후로 세션 ID가 바뀌지 않는다는 사실을 노린다. 공격자가 미리 알고 있는 세션 ID로 피해자를 로그인시키면, 그 세션을 공유하게 된다.
Spring Security의 .sessionFixation().migrateSession()은 로그인 성공 시 새 세션 ID를 발급하면서 기존 속성을 이전한다. 한 줄 설정이지만 효과는 결정적이다.
CSRF는 브라우저가 쿠키를 자동으로 포함한다는 점을 이용한다. Same-Origin Policy는 JavaScript의 크로스도메인 요청을 막지만, HTML 폼 제출은 막지 않는다.
<!-- attacker.com에 심어진 코드 — 피해자가 bank.com에 로그인된 상태면 실행된다 -->
<form method="POST" action="https://bank.com/api/transfer" style="display:none">
<input name="amount" value="100000">
<script>document.forms[0].submit();</script>
</form>
방어는 두 층으로 구성한다. 첫째, SameSite=Strict 쿠키 속성으로 크로스사이트 요청에서 쿠키 전송 자체를 차단한다. 둘째, CSRF 토큰을 HTTP 헤더(X-CSRF-TOKEN)에 담아 폼 제출로는 포함할 수 없게 만든다. 쿠키는 자동 전송되지만 커스텀 헤더는 JavaScript만 설정할 수 있고, JavaScript는 SOP의 제약을 받는다.
OAuth2 — state와 PKCE가 없으면 Authorization Code가 무의미하다
OAuth2 Authorization Code Flow의 state 파라미터는 CSRF 방지용 nonce다. 서버가 요청 시 생성해 Redis에 저장하고, 콜백에서 일치 여부를 검증한다. state가 없으면 공격자가 자신의 Authorization Code를 피해자의 콜백 URL에 주입해 피해자 계정을 자신의 소셜 계정과 연결시킬 수 있다.
PKCE(Proof Key for Code Exchange)는 모바일 앱처럼 client_secret을 안전하게 보관할 수 없는 Public Client를 위한 추가 보호다. 요청 전에 code_verifier를 생성하고, 그 SHA-256 해시인 code_challenge를 Authorization 요청에 포함한다. 토큰 교환 시 code_verifier를 제시해야만 서버가 검증을 통과시킨다. Authorization Code를 탈취해도 code_verifier 없이는 토큰으로 교환할 수 없다.
redirect_uri는 정확한 문자열 일치로 화이트리스트 검증해야 한다. startsWith 검사는 app.example.com.attacker.com이나 app.example.com@attacker.com 같은 우회를 허용한다.
브루트포스 — Rate Limiting이 IP가 아닌 username 기반이어야 하는 이유
Rate Limiting을 IP 기반으로만 구현하면 프록시나 봇넷으로 우회된다. username 기반 슬라이딩 윈도우는 IP를 아무리 바꿔도 동일한 계정에 대한 시도를 누적한다.
계정 잠금(Account Lock)은 직관적이지만 위험하다. 공격자가 의도적으로 다른 사용자의 계정을 5회 실패시키면 그 계정이 잠긴다 — 정상 사용자가 서비스에 접근하지 못하는 DoS 공격이 된다. 영구 잠금 대신 15분 임시 잠금과 사용자 알림 이메일을 조합하면, 보안과 가용성의 균형을 유지할 수 있다.
비밀번호 해시 — MD5와 Bcrypt의 차이는 알고리즘이 아니라 시간이다
MD5와 SHA-1은 빠르다. 빠른 해시 함수는 GPU로 초당 수십억 개의 후보를 검증할 수 있게 만든다. 솔트 없이 저장된 MD5 해시는 Rainbow Table로 밀리초 만에 역추적된다.
Bcrypt는 의도적으로 느리다. Cost 12는 내부적으로 4,096회 반복을 의미하며, 하나의 해시 계산에 수백 ms가 걸린다. 솔트는 자동 생성되어 해시에 포함되므로 Rainbow Table이 무의미하다.
Argon2는 여기서 한 걸음 더 나간다. 메모리를 64MB 요구하도록 설계되어, GPU가 병렬로 수천 개의 해시를 동시에 계산하는 것을 메모리 병목으로 막는다. 새 프로젝트라면 Argon2, 기존 Bcrypt 시스템은 Cost를 12 이상으로 유지하면 충분하다.
레거시 시스템의 마이그레이션은 로그인 시 자동 업그레이드 패턴이 실용적이다. DelegatingPasswordEncoder가 구 알고리즘으로 검증을 통과시키고, 성공 직후 새 알고리즘으로 재해시해 저장한다.
정리
- JWT의
alg,kid는 서버가 화이트리스트로 강제해야 한다. 클라이언트가 알고리즘을 선택하게 허용하면 서명 체계 전체가 무너진다. - 세션은 로그인 후 반드시 새 ID를 발급해야 하고(
migrateSession), 쿠키는HttpOnly + Secure + SameSite=Strict세 속성을 모두 설정해야 한다. - OAuth2의
state와 PKCE는 선택이 아니라 필수다. Public Client에서 PKCE 없는 Authorization Code Flow는 코드를 탈취당하는 순간 무의미해진다. - 비밀번호는 Bcrypt(Cost ≥ 12) 또는 Argon2로 저장한다. MD5나 SHA-1은 데이터베이스 유출 시 몇 분 안에 대부분이 복구된다.
인증 취약점의 공통 원인은 하나다 — 입력을 검증하지 않은 신뢰. 클라이언트가 제시한 알고리즘, 세션 ID, Authorization Code, 비밀번호 후보 — 이 모든 것은 서버가 통제해야 한다.