URL에 예약번호를 실어야 했다 — 개인정보를 봉인 토큰으로 감춘 이야기
예약 완료 후 다음 화면으로 예약번호·이름·전화를 넘겨야 했다. 가장 쉬운 URL 쿼리스트링은 개인정보 평문 노출 + 위변조 위험. 서버 세션 대신 AES-256-GCM 봉인 토큰으로 '무상태 + 위변조 차단'을 동시에 잡은 과정.
예약이 끝나면 예약조회·체크인 같은 다음 화면으로 예약번호·이름·전화 같은 식별정보를 넘겨야 했다. 가장 쉬운 방법은 URL 쿼리스트링이다. 그리고 그게 가장 하면 안 되는 방법이었다.
문제 — 넘겨야 하는 게 개인정보였다
식별정보를 다음 화면에 전달하는 흐름 자체는 흔하다. 문제는 그 값이 개인정보라는 것과, 웹에서 화면 간 값 전달의 1순위 후보인 URL이 누구나 보고 고칠 수 있는 자리라는 것이었다.
URL 쿼리스트링에 예약번호를 그대로 실으면 두 가지가 동시에 터진다.
- 평문 노출 — 주소창·브라우저 히스토리·리퍼러·로그 어디에나 개인정보가 남는다.
- 위변조 — 사용자가 URL의 예약번호를 다른 값으로 바꾸면 남의 예약을 조회할 수 있다.
두 번째가 특히 무섭다. 노출은 "새는" 문제지만, 위변조는 권한 우회다.
선택지 — 세 갈래
| 안 | 방식 | 문제 |
|---|---|---|
| A | URL 평문 전달 | 가장 간단하지만 개인정보 노출 + 위변조 무방비. 탈락 |
| B | 서버 세션에 저장 | 안전하나 멀티테넌트 SSR에서 서버 상태를 늘림. 링크 공유·새로고침 시나리오가 복잡해짐 |
| C | 암호화된 봉인 토큰을 URL로 전달 | 토큰만 노출되고 내용은 서버 키로만 복호화. 무상태 유지 + 위변조 차단 동시 달성 |
B는 정석이다. 하지만 이 서비스는 멀티테넌트 SSR 환경이라 서버 측 상태를 늘리고 싶지 않았고, "링크를 공유하거나 새로고침해도 동작해야 한다"는 요구와도 잘 안 맞았다. 세션은 그 서버·그 세션에 묶이니까.
그래서 C를 택했다. 식별정보를 암호화해서 하나의 토큰으로 봉인하고, 그 토큰만 URL에 실었다. 내용은 서버가 가진 키로만 풀 수 있으니, URL에 노출되는 건 의미 없는 암호문이고 서버는 아무 상태도 저장하지 않는다.
왜 AES-256-GCM이었나 — 암호화만으로는 부족하다
핵심은 암호화 방식 선택이었다. AES-256-GCM(AEAD) 을 골랐다. 이유는 하나다 — 이 문제는 기밀성만으로는 안 풀리기 때문이다.
- 기밀성(내용 숨김) 만 있으면: 남이 토큰을 못 읽는다. 하지만 토큰을 아무렇게나 바꿔서 서버에 던지면?
- GCM은 AEAD라 암호화와 동시에 무결성 인증 태그를 붙인다. 누가 토큰을 한 바이트라도 바꾸면 복호화 단계에서 태그 불일치로 자동 거부된다.
즉 "조용히 통과되는 위조 토큰"이 원천 차단된다. 위변조가 이 문제의 진짜 위협이었으니, 무결성 검증이 암호화와 한 몸으로 붙는 GCM이 정확히 맞았다.
이 지점에서 흔히 나오는 대안들과의 비교:
- "CBC 쓰면 안 되나?" — CBC는 기밀성만 준다. 무결성을 얻으려면 별도로 HMAC을 붙여 직접 조합해야 하는데, 그 과정에서 MAC 검증 누락 같은 실수가 나기 쉽다. GCM은 그걸 프로토콜 차원에서 묶어 준다.
- "JWT 쓰면 되잖아?" — JWT는 기본이 서명이라 페이로드가 그대로 디코딩된다. 서명은 위변조는 막아도 내용을 숨기지 못한다. 여긴 내용(개인정보) 자체를 감춰야 해서 서명이 아니라 암호화가 맞았다. (암호화 JWT인 JWE도 선택지지만, 페이로드가 작고 직접 통제하고 싶어 더 가볍게 갔다.)
// 개념 스케치 — 봉인/개봉의 형태 (실제 구현 아님)
seal(payload) = AES_256_GCM.encrypt(key, payload) → { ciphertext, iv, tag }
open(token) = AES_256_GCM.decrypt(key, token)
// tag 불일치 → throw → "위조 토큰"은 여기서 자동 탈락봉인만으로 끝내지 않았다 — 두 겹 더
암호화가 끝이 아니었다. 토큰이 유출된 상황과 오용된 상황도 가정했다.
- TTL 10분 — 링크가 새어 나가도 짧은 시간만 유효하다. 결제~체크인 동선 길이에 맞춰 잡았다.
- 발급 목적(kind) 구분 — 생성용 토큰과 조회용 토큰을 분리해, 목적이 다른 토큰을 서로 못 쓰게 했다. 한 곳에서 발급된 토큰이 엉뚱한 경로에 재사용되는 걸 막는다.
키는 서버 환경변수로 주입하고 클라이언트에는 노출하지 않았다. (구체적인 키 보관·회전 방식은 이 글의 범위를 넘어가므로 생략한다.)
정리
| 지표 | before(가정: URL 평문) | after(봉인 토큰) |
|---|---|---|
| 개인정보 URL 노출 | 그대로 노출 | 0(암호문만) |
| 위변조 | 값 바꿔 남의 예약 조회 가능 | 자동 차단(GCM 태그 불일치) |
| 서버 상태 | (세션 쓰면) 증가 | 무상태 |
| 유출 시 피해 | 무기한 | TTL 10분으로 제한 |
| 토큰 오용 | — | kind 분리로 교차 사용 차단 |
돌아보면 배운 건 "암호화를 썼다"가 아니라, 이 문제의 위협이 노출이 아니라 위변조였다는 걸 먼저 정확히 짚은 것이었다. 위협을 기밀성 문제로만 봤다면 CBC로도 "암호화했다"고 넘어갔을 것이다. 진짜 막아야 했던 건 "조용히 통과되는 위조 토큰"이었고, 그래서 무결성이 암호화와 한 몸인 AEAD(GCM)가 정답이었다. 암호화 방식 선택은 알고리즘 취향이 아니라, 내가 막으려는 위협이 무엇인지의 문제다.