JWS로 이해하는 JSON 데이터 서명
JWT를 한 번이라도 디코딩해 본 적이 있다면 header.payload.signature처럼 점으로 나뉜 세 조각을 기억하실 텐데요.
앞의 두 조각은 그냥 Base64로 인코딩된 JSON이라 누구나 열어볼 수 있지만, 마지막 서명(signature) 조각이 있어서 토큰의 위변조를 잡아낼 수 있습니다.
그런데 이 서명, 사실 JWT만의 것이 아닙니다. JWS(JSON Web Signature)라는 별도의 표준이 있고, JWT는 그 위에 얹힌 한 가지 응용일 뿐인데요. 이번 글에서는 JWS가 정확히 무엇이고 어떻게 데이터에 서명하는지, 대칭 서명과 비대칭 서명은 어떻게 다른지를 직접 코드를 돌려보며 알아보겠습니다.
JWS란 무엇일까요?
JWS는 임의의 데이터에 디지털 서명을 붙여, 받는 쪽이 위변조 여부를 검증할 수 있게 하는 표준입니다. RFC 7515로 정의되어 있고, JSON 기반 보안 표준 묶음인 JOSE 가족의 핵심 멤버죠.
여기서 중요한 점은 JWS가 다루는 게 “사용자 정보”가 아니라 그냥 바이트 덩어리라는 것입니다. JSON이든 평범한 문자열이든 무엇이든 서명할 수 있어요. JWT는 이 JWS의 페이로드 자리에 사용자 claim을 담은 JSON을 넣은 특수한 경우일 뿐입니다. 그래서 JWS를 이해하면 JWT의 서명 부분이 어떻게 동작하는지 자연스럽게 알게 됩니다.
서명이 보장하는 것은 두 가지인데요. 하나는 무결성(integrity)으로, 데이터가 중간에 바뀌지 않았음을 확인할 수 있습니다. 다른 하나는 진위(authenticity)로, 서명에 쓰인 키를 가진 쪽이 만든 데이터임을 확인할 수 있습니다. 다만 서명은 암호화가 아니라서 내용을 가려주지는 않습니다. 내용을 숨기려면 JWE가 필요합니다.
JWS의 구조
가장 널리 쓰이는 형태는 compact serialization으로, JWT에서 본 것과 똑같이 세 조각을 점으로 잇습니다.
BASE64URL(헤더).BASE64URL(페이로드).BASE64URL(서명)
첫 조각인 헤더에는 서명에 사용한 알고리즘 같은 메타데이터가, 두 번째 조각에는 서명 대상인 페이로드가, 마지막 조각에는 헤더와 페이로드를 합쳐 만든 서명값이 들어갑니다. 세 조각 모두 Base64url로 인코딩되어 있어 URL이나 HTTP 헤더에 그대로 실어 보내기 좋습니다.
직접 서명해보기
JavaScript에서는 jose 라이브러리가 사실상 표준입니다. 설치부터 해보겠습니다.
bun add jose
가장 간단한 대칭키 알고리즘인 HS256(HMAC + SHA-256)으로 문자열 하나에 서명해 보겠습니다.
import { CompactSign, compactVerify } from "jose";
// 대칭키: 서명과 검증에 같은 비밀키를 사용합니다
const secret = new TextEncoder().encode("my-256-bit-secret-key-for-hmac!!");
const jws = await new CompactSign(new TextEncoder().encode("hello jose"))
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
console.log(jws);
eyJhbGciOiJIUzI1NiJ9.aGVsbG8gam9zZQ.NMjjeftRtujDuYwsFsVVNjP8ORpzP459vYeo94Do3U4
점으로 나뉜 세 조각이 보이시나요? 앞의 두 조각은 Base64url이라 그냥 디코딩하면 내용이 그대로 드러납니다.
// 첫 번째 조각(헤더) 디코딩
Buffer.from("eyJhbGciOiJIUzI1NiJ9", "base64url").toString();
// => '{"alg":"HS256"}'
// 두 번째 조각(페이로드) 디코딩
Buffer.from("aGVsbG8gam9zZQ", "base64url").toString();
// => 'hello jose'
보다시피 페이로드는 전혀 암호화되어 있지 않습니다. 서명은 내용을 숨기는 게 아니라 바뀌지 않았음을 보증하는 장치라는 걸 다시 확인할 수 있죠.
검증은 같은 비밀키로 합니다.
const { payload } = await compactVerify(jws, secret);
console.log(new TextDecoder().decode(payload));
hello jose
대칭 서명 vs 비대칭 서명
방금 본 HS256은 대칭키 방식이라 서명하는 쪽과 검증하는 쪽이 똑같은 비밀키를 공유해야 합니다.
같은 서비스 안에서 토큰을 발급하고 검증할 때는 간단하고 빠르지만, 검증하는 쪽이 여럿이라면 그 모두에게 비밀키를 나눠줘야 하는 문제가 생깁니다.
키를 가진 누구나 위조 토큰을 만들 수 있으니까요.
그래서 실무에서는 비대칭키 방식을 더 많이 씁니다. 비대칭키 암호화에서 다뤘듯이 개인키와 공개키가 한 쌍을 이루는데요. 서명은 개인키로만 할 수 있고, 검증은 누구나 공개키로 할 수 있습니다. 덕분에 발급 서버는 개인키를 안전하게 보관하고, 검증하는 쪽에는 공개키만 나눠주면 됩니다.
타원 곡선 기반의 ES256으로 비대칭 서명을 해보겠습니다.
import { CompactSign, compactVerify, generateKeyPair } from "jose";
// 개인키 / 공개키 쌍 생성
const { publicKey, privateKey } = await generateKeyPair("ES256");
// 개인키로 서명
const jws = await new CompactSign(
new TextEncoder().encode("signed by private key"),
)
.setProtectedHeader({ alg: "ES256" })
.sign(privateKey);
// 공개키로 검증
const { payload } = await compactVerify(jws, publicKey);
console.log(new TextDecoder().decode(payload));
signed by private key
개인키로 서명한 토큰이 공개키만으로 검증되는 것을 볼 수 있습니다.
어떤 알고리즘을 골라야 할지, HS256·RS256·ES256이 각각 어떤 트레이드오프를 가지는지는 JWT 서명 알고리즘 비교에서 따로 자세히 다룹니다.
변조하면 어떻게 될까요?
서명의 진가는 데이터가 바뀌었을 때 드러납니다. 토큰의 일부를 슬쩍 고친 뒤 검증해 보겠습니다.
// 서명 조각의 끝부분을 임의로 바꿔치기
const tampered = jws.slice(0, -3) + "AAA";
try {
await compactVerify(tampered, publicKey);
} catch (e) {
console.log("검증 실패:", e.constructor.name);
}
검증 실패: JWSSignatureVerificationFailed
단 세 글자를 바꿨을 뿐인데 검증이 곧바로 실패합니다. 서명은 헤더와 페이로드 전체를 입력으로 계산되기 때문에, 어느 한 글자라도 달라지면 서명값이 맞지 않게 됩니다. 이것이 JWT가 “토큰만 보고도 위조 여부를 알 수 있다”고 말하는 근거입니다.
JWS와 JWT의 관계
지금까지 본 내용을 JWT와 연결하면 그림이 깔끔해집니다.
JWT는 페이로드 자리에 사용자 claim을 담은 JSON을 넣고 서명한 JWS입니다.
앞에서 우리가 "hello jose"라는 문자열을 넣었던 자리에, {"sub":"1234","exp":...} 같은 JSON을 넣으면 그게 바로 JWT인 셈이죠.
그래서 JWT 헤더의 alg, kid 같은 필드가 사실은 JWT가 아니라 JWS(정확히는 JOSE) 헤더의 것이라는 점도 이해가 됩니다.
JWT를 검증한다는 것은 결국 그 안의 JWS 서명을 검증하는 일입니다.
마치며
지금까지 JWS가 임의의 데이터에 서명을 붙이는 독립 표준이라는 점부터, compact 직렬화 구조, 대칭과 비대칭 서명의 차이, 변조 탐지, 그리고 JWT와의 관계까지 살펴봤습니다. 핵심은 서명은 내용을 숨기는 게 아니라 무결성과 진위를 보증한다는 것, 그리고 JWT는 JWS의 한 응용이라는 것입니다.
서명에 쓰는 알고리즘을 어떻게 고를지 궁금하다면 JWT 서명 알고리즘 비교를, 서명이 아니라 암호화가 필요하다면 JWE를 이어서 읽어보세요. JOSE 가족 전체의 그림이 궁금하다면 JOSE 한눈에 보기에서 시작하는 것을 추천합니다.
더 자세한 명세는 RFC 7515 - JSON Web Signature에서 확인할 수 있습니다.
This work is licensed under
CC BY 4.0