본문 바로가기

JWT 토큰이란

1. JWT 토큰 소개

JWT(JSON Web Token)는 인증에 필요한 정보를 안전하게 전달하기 위한 토큰입니다. 쉽게 얘기하여 로그인한 사용자인지 확인하기 위한 토큰입니다. 놀이동산에서 손목에 차는 팔찌와 비슷한 역할을 합니다. 이러한 토큰이 필요한 이유는 HTTP가 무상태(stateless) 프로토콜이기 때문입니다.

개구리 캐릭터 이름은 개리, 곰 캐릭터는 소울곰입니다. 개리가 후드티가 얼마인지 물어봅니다. 그리고 소울곰이 잘 대답을 해주었죠. 개리가 이어서 2개 달라는 요청을 보냅니다. 여기서 문제가 생깁니다. 소울곰은 요청을 보낸 사람이 누구인지 모릅니다. 주는 요청에 따라 응답을 할 뿐이죠. 이러한 문제를 해결하기 위해 토큰을 사용합니다. 이 토큰으로 사용자를 식별할 수 있습니다.

참고로 이렇게 토큰을 이용하지 않고 매 요청마다 ID와 PW를 제공하여 사용자를 식별하게 할 수도 있습니다. 실무에서 사용하진 않지만, 처음에 공부할 때에는 이 방식도 .http 파일로 한 번 경험해보시길 권해드립니다. 비밀번호를 매번 네트워크에 흘려보내야 하고, 서버는 매번 해시를 계산해서 비교해야 한다는 것을 알 수 있습니다. 우리 수업에서는 JWT 토큰을 사용하여 사용자를 식별하겠습니다.

2. JWT 토큰 구조

JWT(JSON Web Token)는 세 가지 주요 구성 요소로 구성됩니다.

구성 요소설명
Header토큰 유형과 서명 알고리즘 지정
Payload클레임(사용자 정보, 유효 기간 등) 포함
Signature토큰이 변조되지 않았음을 검증하는 서명
  1. 헤더(Header): 토큰의 유형과 서명 알고리즘을 지정합니다.
  2. 페이로드(Payload): 토큰에 포함할 클레임(Claim) 정보를 포함합니다. 클레임(Claim)은 토큰에 담을 정보로, 사용자에 대한 정보나 토큰의 유효 기간 등을 포함합니다.
  3. 서명(Signature): 토큰의 유효성을 검증합니다. 서명은 헤더의 인코딩 값과 페이로드의 인코딩 값을 합친 후, 비밀 키로 해싱하여 생성합니다.

이 3가지 구성 요소가 아래와 같이 .으로 연결되어 JWT 토큰이 됩니다.

xxxxx[Header].yyyyy[Payload].zzzzz[Signature]

각 부분은 토큰의 구조와 정보 전달을 위해 중요한 역할을 합니다. 이러한 구성 요소를 함께 사용하여 JWT는 안전하고 효율적인 방식으로 정보를 전달하고 검증합니다. 기본 구조는 아래와 같은 형태입니다.

JWT의 기본 구조

아래 홈페이지에서 값을 바꿔 토큰 값이 어떻게 변하는지 확인할 수 있습니다.

JWT.IO

3. 토큰을 직접 만들고 뜯어보기

어떻게 이 값을 프론트엔드나 백엔드 개발자가 활용하는지 파이썬으로 간단한 실습을 해보도록 하겠습니다. 말로 설명하는 것보다 직접 만들어보는 것이 빠릅니다. 실습용 폴더를 하나 만들고 PyJWT를 설치합니다.

앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 가상환경이 켜져 있다면 deactivate로 빠져나옵니다. 아래 명령은 실습 폴더들을 모아둔 상위 폴더에서 실행하세요.

mkdir 04_3_jwt
cd 04_3_jwt
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install pyjwt

macOS/Linux에서는 python -m venv venv 대신 python3 -m venv venv를, 활성화 명령 대신 source ./venv/bin/activate를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.

jwt_play.py 파일을 만들고 아래 코드를 넣습니다.

import base64
import json

import jwt

SECRET_KEY = "9f2c8e1b47a5d3f6089b2e7c4a1d5f83b6e0c9a2d7f4b1e8c3a6d9f2b5e8c1a4"

# 1. 토큰 만들기
payload = {"sub": "licat", "name": "이호준", "admin": False}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
print("토큰:", token)

# 2. 세 부분으로 나눠보기
header_b64, payload_b64, signature_b64 = token.split(".")
print("\n헤더 부분:", header_b64)
print("페이로드 부분:", payload_b64)
print("서명 부분:", signature_b64)


# 3. 비밀 키 없이 페이로드 읽어보기
def decode_part(part: str) -> dict:
    # base64는 길이가 4의 배수여야 해서 부족한 만큼 '='를 채웁니다
    padded = part + "=" * (-len(part) % 4)
    return json.loads(base64.urlsafe_b64decode(padded))


print("\n비밀 키 없이 읽은 헤더:", decode_part(header_b64))
print("비밀 키 없이 읽은 페이로드:", decode_part(payload_b64))

# 4. 정상적으로 검증하며 읽기
print("\n검증하며 읽은 값:", jwt.decode(token, SECRET_KEY, algorithms=["HS256"]))

# 5. 토큰을 변조해보기
tampered_payload = decode_part(payload_b64)
tampered_payload["admin"] = True  # 관리자로 바꿔치기 시도
tampered_b64 = base64.urlsafe_b64encode(
    json.dumps(tampered_payload).encode()
).decode().rstrip("=")
tampered_token = f"{header_b64}.{tampered_b64}.{signature_b64}"

try:
    jwt.decode(tampered_token, SECRET_KEY, algorithms=["HS256"])
except jwt.InvalidTokenError as e:
    print("\n변조된 토큰 검증 결과:", type(e).__name__, "-", e)

실행합니다.

python jwt_play.py

출력된 토큰을 JWT.IO에 붙여넣으면 헤더와 페이로드가 그대로 풀려 보이는 것도 확인할 수 있습니다. 페이로드 부분을 base64로 직접 풀 때는 등호를 채워 길이를 4의 배수로 맞춰야 하는데, 위 코드의 decode_part가 그 일을 합니다.

비밀 키는 길어야 합니다

SECRET_KEY에 "my-secret-key" 같은 짧은 문자열을 넣으면 아래와 같은 경고가 나옵니다.

InsecureKeyLengthWarning: The HMAC key is 13 bytes long,
which is below the minimum recommended length of 32 bytes for SHA256.

HS256 알고리즘은 32바이트 이상의 키를 권장합니다. 키가 짧으면 서명을 무차별 대입으로 알아낼 수 있기 때문입니다. 위 코드에 넣은 값은 64자리 16진수 문자열입니다.

직접 만들려면 아래 명령을 사용하세요.

python -c "import secrets; print(secrets.token_hex(32))"

그리고 이 값은 코드에 적어두면 안 됩니다. 지금은 학습용이라 그대로 두지만, 실제 서비스에서는 환경 변수로 관리해야 합니다. 방법은 7장에서 다룹니다.

3.1 실행 결과에서 확인할 것

이렇게 하면 프론트엔드와 백엔드에서 모두 페이로드 부분을 읽을 수 있습니다. 이를 통해 알 수 있는 것은 2가지입니다. 중요한 포인트이니 꼭 기억해주세요.

  1. 사용자 정보를 암호화하기 위한 알고리즘은 아닙니다. 3번 단계에서 비밀 키 없이도 {"sub": "licat", "name": "이호준", "admin": false}가 그대로 나왔습니다. JWT는 암호화가 아니라 인코딩입니다. 프론트엔드나 백엔드에서 모두 해당 정보를 볼 수 있습니다. 해당 정보가 탈취되면 사용자로 위장하거나 그 정보를 볼 수 있습니다. 그러므로 주민등록번호, 비밀번호, 전화번호 같은 정보를 페이로드에 넣으면 안 됩니다. 탈취는 HttpOnly 쿠키를 이용하여 어느 정도 방어할 수 있습니다.
  2. 토큰이 변조가 불가하다는 것입니다. 5번 단계에서 admin을 true로 바꾼 토큰을 검증하려 하자 InvalidSignatureError가 발생했습니다. 앞에 문자열이 바뀌면 뒤에 서명이 바뀝니다. 이 서명을 통해 이것이 변조되었는지 변조되지 않았는지 체크할 수 있습니다. 서명을 다시 만들려면 비밀 키가 필요하기 때문에, 로그인한 사용자가 licat이고 관리자가 아니라면 이를 변조할 수는 없습니다.

JWT 토큰은 주민등록증과 비슷합니다. 주민등록증에는 이름, 주민번호, 주소 등이 적혀 있습니다. 이 정보를 토대로 해당 사람이 누구인지 확인할 수 있습니다. 그러나 주민등록증을 탈취하면 그 정보를 알 수 있습니다. 또한 얼굴이 비슷한 분이 탈취했다면 그 정보를 활용할 수도 있습니다. 다만 그렇다 해서 탈취한 주민등록증에 주민등록 번호를 바꾸게 되면 어떻게 될까요? 주민등록증을 누군가 조회한다면 조회해서 안나올겁니다. JWT 토큰도 마찬가지입니다. 토큰을 탈취하면 정보를 볼 수 있고, 위장을 할 수도 있습니다. 다만, 변조는 되지 않습니다.

완벽한 보안은 없습니다. 열리지 않는 자물쇠는 없다라는 말처럼, 우리는 열기 힘든 자물쇠를 만드는 것입니다. 어떤 기술을 채택할 때 대부분 기술은 장점과 단점이 공존합니다. 따라서 우리 서비스에 어떤 보안 기술이 적합할지 비교해보아야 합니다.

3.2 만료 시간 넣어보기

JWT의 페이로드에는 표준으로 정해진 이름들이 있습니다. 그중 가장 많이 쓰는 것이 exp(만료 시각)와 sub(주체, 보통 사용자 식별자)입니다.

jwt_expire.py 파일을 만들어 실행해보세요.

import time
from datetime import datetime, timedelta, timezone

import jwt

SECRET_KEY = "9f2c8e1b47a5d3f6089b2e7c4a1d5f83b6e0c9a2d7f4b1e8c3a6d9f2b5e8c1a4"

# 3초 뒤에 만료되는 토큰
payload = {
    "sub": "licat",
    "exp": datetime.now(timezone.utc) + timedelta(seconds=3),
}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")

print("바로 검증:", jwt.decode(token, SECRET_KEY, algorithms=["HS256"]))

print("4초 기다립니다...")
time.sleep(4)

try:
    jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
    print("만료된 토큰입니다.")

exp를 넣어두면 PyJWT가 검증 시점에 만료 여부를 자동으로 확인해줍니다. 우리가 시간을 비교하는 코드를 쓸 필요가 없습니다.

datetime.utcnow()를 쓴 코드를 봤다면

인터넷 자료에는 아래처럼 적힌 코드가 많습니다.

# 오래된 방식, 파이썬 3.12부터 경고가 나옵니다
expire = datetime.utcnow() + timedelta(minutes=30)

utcnow()는 시간대 정보가 빠진 값을 돌려주기 때문에, 다른 시간대의 값과 비교할 때 조용히 틀린 결과를 만들어냅니다. 지금은 아래처럼 씁니다.

expire = datetime.now(timezone.utc) + timedelta(minutes=30)

토큰 만료 시각처럼 시간을 비교하는 코드에서 이 차이는 실제 버그로 이어집니다. 앞으로 이 책은 datetime.now(timezone.utc)로 통일합니다.

4. JWT 토큰 발행 프로세스

JWT 토큰을 발행하는 프로세스는 다음과 같습니다.

  1. 사용자 로그인과 토큰 발행: 사용자가 로그인을 하면 서버는 사용자 정보를 확인하여 토큰을 발행합니다.
  2. 토큰 전송: 서버는 사용자에게 토큰에 담아 전송합니다. 이때 사용자는 적절한 곳에 토큰을 저장합니다.
  3. 홈페이지 활동: 사용자는 홈페이지에서 활동을 합니다. 이때 서버는 토큰을 확인하여 사용자를 식별합니다. 장바구니, 게시글 작성, 댓글 작성 등을 할 수 있습니다. 로그인이 필요한 모든 요청에는 토큰이 필요합니다.
  4. 토큰 만료: 토큰은 만료 기간이 있습니다. 만료 기간이 지나면 서버는 사용자를 로그아웃 시키고, 토큰이 만료되었다는 에러를 반환합니다.
  5. 토큰 갱신: 토큰이 만료되면 리프레시 토큰을 이용하여 토큰을 갱신하려는 요청을 시도합니다.
  6. 토큰 갱신 성공: 서버는 리프레시 토큰을 확인하여 토큰을 갱신합니다. 이때 사용자는 새로운 토큰을 받습니다. 리프레시 토큰도 만료 기간이 있습니다. 만약 리프레시 토큰이 만료되면 로그인을 다시하라는 에러를 반환합니다. 이 경우 1번으로 되돌아가게 됩니다.

4.1 액세스 토큰과 리프레시 토큰

토큰이 두 종류인 이유가 궁금할 수 있습니다.

액세스 토큰리프레시 토큰
용도모든 요청에 첨부새 액세스 토큰을 받을 때만 사용
수명짧게 (15분에서 30분)길게 (며칠에서 몇 주)
노출 빈도높음낮음
서버 저장보통 하지 않음보통 저장해서 무효화할 수 있게 함

액세스 토큰의 수명이 짧은 이유는 탈취되었을 때의 피해를 줄이기 위해서입니다. JWT는 한 번 발급하면 서버가 취소할 방법이 없기 때문에, 수명 자체를 짧게 두는 것이 사실상 유일한 방어책입니다.

이 책의 실습에서는 복잡도를 낮추기 위해 액세스 토큰만 구현합니다. 리프레시 토큰은 연습문제로 남겨두었습니다.

5. 토큰 사용방식

토큰을 어떻게 프론트엔드에서 주고 받는지 테스트해보겠습니다. 이 예제는 실제 JWT 토큰의 방식을 따르고 있진 않으며, 이해를 돕기 위해 간소화 했놓은 것입니다. 아래 위니브 API 명세를 참고해주세요. 참고로 이 서버도 FastAPI를 사용하고 있습니다.

위니브 API 명세

프론트엔드에서는 아래와 같이 토큰을 받아옵니다. about:blank로 접속하여 개발자 도구를 연 다음 아래 코드를 입력해주세요. 여기서 13은 여러분이 변경 가능한 값입니다.

fetch("https://eduapi.weniv.co.kr/13/signup", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    username: "test123",
    password: "test1234",
  }),
})
  .then((response) => response.json())
  .then((json) => console.log(json))
  .catch((error) => console.error(error));

위 코드는 유저를 생성하는 코드입니다. 유저를 생성했으니 이제 로그인을 해보겠습니다.

fetch("https://eduapi.weniv.co.kr/13/login", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    username: "test123",
    password: "test1234",
  }),
})
  .then((response) => response.json())
  .then((json) => console.log(json))
  .catch((error) => console.error(error));

로그인을 하면 토큰을 받을 수 있습니다. 이렇게 받은 토큰으로 앞으로 요청할 때 아래와 같이 토큰을 넣어서 보내면 서버는 해당 토큰을 확인하여 사용자를 식별합니다.

fetch("https://eduapi.weniv.co.kr/login_confirm", {
  method: "POST",
  headers: {
    Authorization: "Bearer eyJhbGciOi.weniv.h8t7NJKEiWCh7G3",
  },
})
  .then((response) => response.json())
  .then((json) => console.log(json))
  .catch((error) => console.error(error));

토큰은 이렇게 Authorization 헤더에 Bearer 접두사를 붙여 보냅니다. Bearer는 "이것을 가진 사람"이라는 뜻입니다. 이 형식은 OAuth2 표준에서 정한 것이며, 다음 절에서 FastAPI가 이 형식을 어떻게 자동으로 처리해주는지 보게 됩니다. .http 파일에서는 아래와 같이 적습니다.

### 보호된 경로 접근
GET http://127.0.0.1:8000/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsaWNhdCJ9.abc

5.1 토큰의 저장 위치

매번 로그인을 할 수는 없으니 이 토큰을 어딘가에 저장해야 하는데, 크게 세 가지 선택지가 있습니다.

저장 위치브라우저를 껐다 켜면JavaScript로 읽기주요 위험
로컬 스토리지남아 있습니다가능XSS 공격에 취약
세션 스토리지사라집니다가능XSS 공격에 취약
HttpOnly 쿠키만료 설정에 따라 다릅니다불가능CSRF 공격에 대비 필요

로컬 스토리지와 세션 스토리지는 JavaScript로 읽고 쓸 수 있습니다. 편리하지만, 페이지에 악성 스크립트가 한 줄이라도 끼어들면 토큰을 그대로 가져갈 수 있습니다. 이것을 XSS(Cross-Site Scripting) 공격이라고 합니다.

HttpOnly 쿠키는 JavaScript가 접근할 수 없는 쿠키입니다. 브라우저가 요청할 때 자동으로 붙여 보내지만 스크립트로는 읽을 수 없어 XSS에 강합니다. 대신 자동으로 붙어 나간다는 성질 때문에 CSRF(Cross-Site Request Forgery) 공격에 대한 대비가 별도로 필요합니다.

실무에서는 HttpOnly 쿠키를 권장하는 경우가 많습니다. 이 책에서는 코드가 눈에 보이고 개발자 도구에서 값을 확인하기 쉽다는 이유로 로컬 스토리지를 사용합니다. 학습용이라는 점을 기억해두시고, 실제 서비스를 만들 때는 다시 검토하시기 바랍니다.

여기서는 간단하게 로컬스토리지에 저장하는 방법을 알아보겠습니다. html 전체 소스코드입니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>로컬스토리지</title>
</head>
<body>
<button id="login">로그인</button>
<button id="confirm">확인</button>
<script>
    document.getElementById("login").addEventListener("click", () => {
        fetch("https://eduapi.weniv.co.kr/13/login", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                username: "test123",
                password: "test1234",
            }),
        })
        .then((response) => response.json())
        .then((json) => {
            localStorage.setItem("token", json.access_token);
            console.log(json);
        })
        .catch((error) => console.error(error));
    });

    document.getElementById("confirm").addEventListener("click", () => {
        fetch("https://eduapi.weniv.co.kr/login_confirm", {
            method: "POST",
            headers: {
                Authorization: `Bearer ${localStorage.getItem("token")}`,
            },
        })
        .then((response) => response.json())
        .then((json) => console.log(json))
        .catch((error) => console.error(error));
    });
</script>
</body>
</html>

이제 프론트엔드에서 토큰을 받아오는 방법을 알았으니, 다음 절에서 백엔드가 토큰을 발행하고 검증하는 코드를 직접 만들어보겠습니다.

연습문제

  1. jwt_play.py에서 SECRET_KEY를 다른 값으로 바꿔 jwt.decode를 시도해보세요. 어떤 예외가 발생하는지 확인해보세요.
  2. 페이로드에 role: "admin"을 넣은 토큰을 만들고, jwt.io에 붙여넣어 화면에서 어떻게 보이는지 확인해보세요.
  3. algorithm="HS256" 대신 다른 알고리즘 이름을 넣으면 어떻게 되는지 확인해보세요.
  4. 만료 시간을 1시간 뒤로 설정한 토큰을 만들고, 페이로드를 직접 디코딩해서 exp 값이 어떤 형태로 들어 있는지 확인해보세요.