← Posts

JWT · Token · Authentication

JWT - 서버가 기억하지 않는다

토큰 안에 신원을 담고 위조만 막는다. 세 조각 구조와 검증 절차, 그리고 서명은 암호화가 아니라는 사실.

목차
  1. 토큰 안에 내 이메일이 그대로 보인다
  2. 서버가 기억하지 않으려면
  3. 점 두 개로 나뉜 세 조각
  4. 서명은 잠그는 게 아니라 봉인이다
  5. 검증은 무엇을 확인하나
  6. 비밀 키를 나눠 갖기 곤란할 때
  7. 만료가 사실상 유일한 브레이크다
  8. 실무에서 토큰을 어디에 두나
  9. 남는 질문
  10. 정리

로그인 응답으로 받은 긴 문자열을 아무 디코더에나 붙여넣었더니, 내 이메일이 그대로 읽혔다.

토큰 안에 내 이메일이 그대로 보인다

로그인 응답이 이렇게 왔다고 하자.

json
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3IiwiZW1haWwiOiJraW1AZXhhbXBsZS5jb20iLCJyb2xlIjoiVVNFUiJ9.qX8s..."
}

암호처럼 생겼다. 그런데 가운데 조각을 Base64로 디코드하면 이게 나온다.

json
{ "sub": "7", "email": "kim@example.com", "role": "USER" }

키도 비밀번호도 없이 읽혔다. 여기서 두 가지 반응이 갈린다. “이거 뚫린 거 아닌가?”와 “원래 이런 건가?”다.

원래 이런 것이다. 그리고 이 사실을 모르고 토큰을 쓰면 실제로 다친다. 그래서 이 글은 구조를 보고, 무엇이 지켜지고 무엇이 안 지켜지는지까지 간다.

서버가 기억하지 않으려면

앞 글에서 세션은 이런 모양이었다. 브라우저는 뜻 없는 번호표를 들고, 그 번호가 누구인지는 서버가 기억한다. 그래서 요청마다 서버가 저장소를 뒤져야 했고, 서버를 늘리면 그 저장소를 공유해야 했다.

반대로 가보자. 아예 브라우저가 든 값 안에 “누구인지”를 적어두면 서버는 아무것도 기억할 필요가 없다.

diagramdiagram

문제는 앞 글에서 이미 봤다. 브라우저에 있는 값은 사용자가 고칠 수 있다. role: USERrole: ADMIN으로 바꿔서 보내면 서버가 그걸 믿어버린다.

그래서 필요한 건 하나다. 읽는 건 상관없지만, 고치면 들통나게 만드는 것.

이걸 하는 표준 형식이 JWT(JSON Web Token) 다. 이름 그대로 JSON을 담아 나르는 토큰이고, 규격은 RFC 7519에 있다.

점 두 개로 나뉜 세 조각

JWT는 눈으로 봐도 구조가 보인다. 점(.)이 두 개 있고, 그래서 세 조각이다.

plaintext
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI3Iiw... . qX8sK3nF...
└──────── 헤더 ────────┘   └── 페이로드 ──┘   └── 서명 ──┘

앞의 두 조각은 그냥 JSON을 Base64URL로 인코딩한 것이다.

헤더는 이 토큰을 어떻게 다뤄야 하는지 적는다.

json
{ "alg": "HS256", "typ": "JWT" }

alg는 서명에 쓴 알고리즘이다. 검증하는 쪽이 “어떤 방식으로 확인해야 하는지” 알아야 하니 여기 적힌다.

페이로드는 담고 싶은 내용이다. 각 항목을 클레임(claim) 이라고 부른다.

json
{
  "sub": "7",
  "role": "USER",
  "iat": 1780000000,
  "exp": 1780003600
}

규격이 이름을 미리 정해둔 클레임이 몇 개 있다.

클레임
sub주체(subject). 이 토큰이 가리키는 대상의 식별자
iss발급자(issuer). 누가 이 토큰을 만들었나
aud대상(audience). 누가 쓰라고 만든 토큰인가
exp만료 시각. 이 시각이 지나면 무효
iat발급 시각

role처럼 우리가 정한 이름도 넣을 수 있다. 이름이 미리 정해진 것은 여러 시스템이 같은 뜻으로 읽어야 하는 항목들이다.

서명은 세 번째 조각이고, 이게 이 형식의 핵심이다.

서명은 잠그는 게 아니라 봉인이다

여기가 이 글에서 가장 중요한 대목이다. 많은 사람이 서명을 “암호화”로 오해한다.

서명은 암호화가 아니다.

  • 암호화는 내용을 못 읽게 만든다. 열쇠가 없으면 안이 안 보인다.
  • 서명은 내용을 못 고치게 만든다. 안은 그대로 보이지만, 손대면 티가 난다.

편지 봉투에 비유하면, 암호화는 내용을 암호로 바꿔 적은 것이고 서명은 투명한 봉투에 넣고 봉인 스티커를 붙인 것이다. 투명하니까 밖에서 다 읽힌다. 대신 뜯으면 스티커가 찢어져서 뜯었다는 사실이 드러난다.

diagramdiagram

서명을 만드는 절차 자체는 단순하다. 헤더와 페이로드를 이어붙인 문자열서버만 아는 비밀 키를 함께 넣어 계산한 값이다.

plaintext
서명 = HMAC-SHA256( base64(헤더) + "." + base64(페이로드), 비밀 키 )

여기서 두 가지가 따라 나온다.

  • 입력이 1비트라도 달라지면 결과가 완전히 달라진다. 그래서 페이로드를 고치면 서명이 안 맞는다.
  • 비밀 키가 없으면 올바른 서명을 만들 수 없다. 그래서 공격자는 고친 내용에 맞는 새 서명을 붙일 수 없다.

roleADMIN으로 바꿔서 보내면 서버는 이렇게 판단한다.

diagramdiagram

고칠 수는 있다. 고친 게 통하지 않을 뿐이다.

경고

그래서 JWT에 민감한 정보를 담으면 안 된다. 주민등록번호, 전화번호, 주소, 내부 시스템의 비밀 식별자 같은 것. 토큰을 가진 사람은 누구나 페이로드를 읽는다. 브라우저의 로컬 저장소에 있는 토큰도, 로그에 찍힌 토큰도, 프록시를 지나간 토큰도 전부 읽힌다. “봉인돼 있으니 안전하겠지”는 이 형식에 대한 정확히 반대의 이해다. 담아도 되는 것은 남이 봐도 곤란하지 않은 식별자와 최소한의 권한 정보다.

검증은 무엇을 확인하나

토큰을 받은 서버가 하는 일은 순서가 정해져 있다.

diagramdiagram

이 절차 전체에 데이터베이스 조회가 한 번도 없다. 서명 계산과 시각 비교뿐이다. 이게 “서버가 기억하지 않는다”의 실체다. 서버가 여러 대여도 같은 비밀 키만 있으면 어느 서버든 똑같이 검증한다.

Spring Security로 쓰면 검증 절차를 직접 짤 일은 거의 없다.

주의

라이브러리가 다 해준다고 믿으면 안 된다. 이런 디코더가 기본으로 확인하는 건 서명과 만료 시각까지다. 누가 발급했는지(iss)와 누구에게 준 토큰인지(aud)는 따로 걸어줘야 한다. 안 걸면 서명 키만 같으면 다른 서비스용 토큰도 통과한다. 위에서 발급자 검사를 덧붙인 이유다.

java
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(secretKey).build();
    decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(ISSUER));  // 발급자까지 검사
    return decoder;
}

// 검증을 통과하면 컨트롤러에서 클레임을 꺼내 쓴다
@GetMapping("/me")
public String me(@AuthenticationPrincipal Jwt jwt) {
    return jwt.getSubject();          // sub
}
주의

검증에서 헤더의 alg를 그대로 믿으면 안 된다. 그 값은 토큰 안에 들어 있고, 토큰은 보낸 쪽이 만든 것이다. 서버는 “내가 받아들일 알고리즘”을 미리 정해두고 그것과 다르면 거부해야 한다. 라이브러리가 대체로 이걸 강제하지만, 직접 파싱하는 코드를 짜면 놓치기 쉬운 자리다.

비밀 키를 나눠 갖기 곤란할 때

지금까지 본 방식은 서명과 검증에 같은 키를 쓴다(HS256). 만드는 쪽과 확인하는 쪽이 같은 조직이면 이걸로 충분하다.

문제는 검증하는 쪽이 여럿일 때다. 인증 서버가 토큰을 발급하고 여러 서비스가 각자 검증한다면, 그 서비스들에 전부 비밀 키를 나눠줘야 한다. 검증만 하면 되는 곳까지 토큰을 발급할 능력을 갖게 된다. 그중 한 곳이 뚫리면 공격자가 원하는 토큰을 마음대로 만든다.

그래서 키를 둘로 나누는 방식이 있다(RS256 등).

대칭 키 (HS256)비대칭 키 (RS256)
서명할 때비밀 키개인 키 (발급자만)
검증할 때같은 비밀 키공개 키 (누구나)
검증자에게 주는 것발급 능력까지검증 능력만
어울리는 곳발급·검증이 한 몸발급자와 검증자가 갈린 구조

공개 키는 이름 그대로 공개해도 된다. 그걸로는 검증만 되고 발급은 안 되기 때문이다. 그래서 발급자가 공개 키를 URL로 게시해두고 검증자가 받아가는 구조가 흔하다. 다음 글 이후에 볼 외부 인증 제공자들이 이 모양이다.

만료가 사실상 유일한 브레이크다

세션은 서버가 지우면 그 순간 끝났다. 토큰은 그럴 자리가 없다.

서버가 아무것도 기억하지 않는다는 것은, 취소할 목록도 없다는 뜻이다. 한번 발급된 토큰은 exp에 적힌 시각이 될 때까지 유효하다. 로그아웃 버튼을 눌러도, 계정을 정지시켜도, 권한을 회수해도 그렇다.

diagramdiagram

로그아웃이란 게 클라이언트가 자기 저장소에서 토큰을 지우는 일이 되어버린다. 그 전에 어딘가로 복사된 토큰은 그대로 살아 있다.

그래서 실무는 두 가지로 대응한다.

만료를 짧게 잡는다. 접근 토큰(access token)의 수명을 분 단위로 짧게 둔다. 탈취되더라도 살아 있는 시간이 짧다. 대신 사용자가 몇 분마다 로그인해야 하면 못 쓴다.

갱신 토큰(refresh token)을 따로 둔다. 수명이 긴 갱신 토큰을 하나 더 주고, 접근 토큰이 만료되면 그걸로 새로 받아온다.

diagramdiagram

여기서 눈여겨볼 게 있다. 갱신 토큰은 대개 서버가 기억한다. 폐기할 수 있어야 하기 때문이다. 로그아웃하면 갱신 토큰을 지우고, 그러면 길어야 접근 토큰의 남은 수명만큼 뒤에는 확실히 끊긴다.

참고

그러니까 실무의 토큰 방식은 “서버가 아무것도 기억하지 않는” 순수한 형태가 아닌 경우가 많다. 자주 오는 요청(API 호출)에서 조회를 없애고, 드물게 오는 요청(갱신)에만 조회를 남기는 절충에 가깝다. 이 절충을 어디까지 할지가 다음 글의 주제다.

실무에서 토큰을 어디에 두나

브라우저 앱에서 받은 토큰을 어디에 보관할지는 정답이 하나로 정해져 있지 않다.

위치성질
자바스크립트 변수(메모리)새로고침하면 사라진다. 스크립트가 읽을 수는 있다
로컬 저장소새로고침해도 남는다. 스크립트가 읽을 수 있다
HttpOnly 쿠키스크립트가 못 읽는다. 대신 요청마다 자동으로 붙어서 CSRF라는 다른 고려가 생긴다

세 번째를 보면 이상한 일이 벌어진다. 토큰을 쿠키에 넣으면 앞 글에서 본 세션 쿠키와 겉모습이 거의 같아진다. 다른 점은 그 쿠키 안에 뜻 없는 번호가 들었느냐, 서명된 정보가 들었느냐뿐이다.

이게 두 방식의 관계를 잘 보여준다. 쿠키냐 헤더냐는 나르는 방법이고, 세션이냐 토큰이냐는 상태를 어디에 두느냐다. 둘은 다른 축이라 자유롭게 조합된다. 이걸 뭉개면 “쿠키 = 세션, 헤더 = JWT”라는 잘못된 짝이 머리에 박힌다.

한편 서버 쪽에서는 서명 키 관리가 실무의 절반이다.

  • 키를 소스 코드나 저장소에 넣지 않는다
  • 짧고 예측 가능한 문자열을 키로 쓰지 않는다
  • 키를 교체할 수 있는 구조를 미리 만들어둔다

키가 새면 공격자가 아무 내용의 토큰이나 만들 수 있다. 세션에서 저장소를 잃는 것과는 피해의 성격이 다르다. 세션 저장소가 털리면 그 안의 세션들이 위험하지만, 서명 키가 털리면 아직 존재하지 않는 사용자의 토큰까지 만들어진다.

남는 질문

이제 두 방식이 다 나왔다. 하나는 서버가 기억하고, 하나는 기억하지 않는다.

여기서 흔한 결론이 하나 있다. “JWT가 최신이고 확장에 유리하니 이걸 쓰면 된다.” 그런데 방금 본 것만 봐도 그렇게 단순하지 않다. 즉시 로그아웃이 안 되고, 그걸 해결하려면 결국 서버가 무언가를 기억하기 시작한다.

반대로 “세션은 옛날 방식”이라는 말도 정확하지 않다. 지금도 수많은 서비스가 세션으로 잘 돌아간다.

그러니 남는 질문은 어느 쪽이 나으냐가 아니라 무엇을 보고 고르느냐다. 다음 글에서 둘을 정면으로 견준다.

정리

JWT란신원 정보를 담고 서명으로 위조만 막은 토큰
구조헤더 · 페이로드 · 서명, 점 두 개로 나뉜 세 조각
서명은암호화가 아니다. 못 읽게 하는 게 아니라 못 고치게 한다
그래서페이로드는 누구나 읽는다. 민감정보를 담지 않는다
검증서명 · 만료 · 발급자를 확인한다. 저장소 조회가 없다
키 방식발급·검증이 갈리면 비대칭 키로 검증 능력만 넘긴다
약점즉시 무효화가 안 된다. 만료가 사실상 유일한 브레이크
실무짧은 접근 토큰 + 서버가 기억하는 갱신 토큰

서버가 기억하지 않는 대신, 서버가 통제하지 못한다. 세션과 정확히 반대다.

관련 글