L1FoundationsAccess Control
토큰·키·서명이란 무엇인가
· 시리즈: ladder · Read in English
1. 한 줄 위협
토큰만 검사하는 API는 "키를 가진 누군가가 보냈다"는 알아도 "요청 내용이 도중에 바뀌지 않았는가"는 모른다. 토큰을 주워 그대로 쓰거나, 유효한 요청의 본문만 바꾼 공격자가 통과한다.
2. 개념
셋은 같은 줄에 놓이는 단어가 아니다. 증명하는 대상이 다르다.
- 토큰(token) = 소지증(bearer). 가진 사람이 곧 본인으로 취급된다. 주우면 누구나 쓸 수 있다 — "누가"를 (그것도 약하게) 가리킬 뿐, 내용이 그대로인지는 말하지 않는다.
- 키(key) = 비밀 그 자체. 신원이 아니라 서명을 만들고 검증하는 재료다. 양쪽이 같은 비밀을 나눠 가지면 대칭키, 개인키로 서명하고 공개키로 검증하면 비대칭키다.
- 서명(signature) = 키로 요청 내용 위에 찍은 봉인. 내용이 한 글자라도 바뀌면 봉인이 깨진다. 그래서 무결성 + 발신자 인증을 함께 증명한다. 비대칭이면 제3자에게도 증명되는 부인방지까지 간다.
즉 토큰은 "이걸 가진 사람"을, 서명은 "이 내용을 이 비밀을 가진 쪽이 보냈다"를 증명한다. 토큰을 서명 자리에 쓰면 무결성이 빈다.
| 도구 | 비밀 | 요청 내용에 묶이나 | 증명 |
|---|---|---|---|
| 토큰(bearer) | 소지=권한 | ✗ | 소지자 식별(누가) — 새면 누구나 |
| 키 | 비밀 그 자체 | — | 서명/검증의 재료(단독으론 증명 아님) |
| 서명 | 키로 생성 | ✓ | 무결성 + 인증 (비대칭=부인방지) |
3. 취약한 코드
"토큰이 맞으면 통과"하는 가장 흔한 패턴이다. 본문은 검증하지 않는다.
// 안티패턴: 토큰 문자열만 일치하면 통과 — 요청 본문은 보지 않는다
function authorize(token: string, body: string): boolean {
// body 는 받기만 하고 검증에 쓰이지 않는다
return token === API_TOKEN; // 소지 확인뿐 + === 동등 비교까지 취약
}
4. 공격 원리
세 가지가 겹친다.
- 토큰은 소지증이다. RFC 6750은 bearer 토큰을 "가진 사람이면 누구나 그대로 쓸 수 있고, 암호 키 소유를 증명할 필요가 없는" 토큰으로 정의한다. 로그·URL·중간자로 한 번 새면 공격자가 그대로 재사용한다.
- 토큰은 요청 내용에 묶이지 않는다. 유효한 토큰이 실린 요청을 가로채 본문(금액·수신자·경로)을 바꿔도 토큰은 그대로 유효하다. 서버는 변조를 느끼지 못한다 — 토큰은 "누가"만 말하고 "무엇을"은 말하지 않기 때문이다.
===비교는 타이밍을 흘린다. 문자열 비교는 첫 불일치 바이트에서 즉시 끝나, 앞에서 몇 바이트가 맞았는지가 응답 시간으로 새어 나간다. 공격자는 토큰/태그를 한 바이트씩 맞춰 나갈 수 있다.
5. 방어 구현
역할을 나눈다. "누가"는 토큰, "내용이 그대로인가 + 발신자"는 서명이 맡는다. 키(비밀)로 요청 본문에 HMAC 서명을 만들어 묶고, 비교는 상수시간으로 한다.
import { createHmac, timingSafeEqual } from "node:crypto";
// 키(비밀)로 요청 본문 위에 서명을 만든다 — 본문이 바뀌면 서명이 깨진다
function sign(secret: string, body: string): Buffer {
return createHmac("sha256", secret).update(body).digest();
}
function verify(secret: string, body: string, sigHex: string): boolean {
const expected = sign(secret, body);
const got = Buffer.from(sigHex, "hex");
// 길이가 다르면 timingSafeEqual이 throw → 먼저 막는다
if (got.length !== expected.length) return false;
return timingSafeEqual(got, expected); // 상수시간 비교
}
| Decision | Why | Rejected |
|---|---|---|
| 신원은 토큰, 무결성은 서명으로 분리 | 토큰은 "누가"만, 서명은 요청 내용을 묶어 증명 | 토큰 단독: 본문 변조·재사용 무방비 |
| 요청 본문에 HMAC 서명 | 내용이 바뀌면 태그가 깨짐 → 무결성 + 인증 | 본문 검증 없음: 가로챈 요청 변조가 통과 |
timingSafeEqual 상수시간 비교 | 매칭 바이트 수가 시간으로 새지 않음 | ===: 타이밍 사이드채널로 토큰/태그 추정 |
| 제3자 증명이 필요하면 비대칭 서명 | 개인키 서명 → 공개키 검증, 부인방지 확보 | HMAC: 공유 비밀이라 누가 만들었는지 제3자 증명 불가 |
토큰은 TLS 위에서만 보내고 URL·로그에 남기지 않는다. 소지=권한이라 새는 순간 끝이다(RFC 6750). 서명에 타임스탬프를 함께 묶으면 재전송도 좁힐 수 있다 — AWS SigV4는 서명 요청을 몇 분 내로만 받는다. (재전송 차단은 W6에서 깊이 다룬다.)
6. 실전 체크리스트
- 접근통제(access): 토큰("누가") 외에 요청 본문을 서명으로 묶었는가
- bearer 토큰을 TLS 위에서만 보내고 URL·로그에 노출하지 않는가
- 토큰·서명 비교가 상수시간(
timingSafeEqual)인가 - 대칭(HMAC)/비대칭(공개키) 중 부인방지가 필요한지 판단했는가
- 토큰은 짧은 수명 + 폐기 경로가 있는가
- 비밀(키)은 환경변수·KMS에 있고 코드·로그에 없는가