0Pricing
FastAPI Backend Development Bootcamp · 강의

새로 고침 토큰과 토큰 순환

토큰 탈취를 완화하도록 수명이 짧은 액세스 토큰, 순환하는 새로 고침 토큰 및 서버 측 폐기를 설계합니다.

새로 고침 토큰과 토큰 순환은(는) CoddyKit의 무료 FastAPI Backend Development Bootcamp 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 FastAPI Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why Two Tokens?

A single long-lived JWT access token is convenient but dangerous: if it leaks, an attacker can use it until it expires. Since JWTs are stateless, you cannot easily revoke one mid-flight.

The standard OAuth2 answer is to split responsibilities into two tokens:

  • Access token — short-lived (5-15 min), sent on every request, verified by signature only (no DB hit).
  • Refresh token — long-lived (days/weeks), used only to obtain a new access token, and tracked server-side so it can be revoked.

This way a stolen access token is useless within minutes, while users still stay logged in for a long time.

Anatomy of the Token Pair

The login endpoint returns both tokens. The client keeps the access token in memory and the refresh token somewhere more protected (e.g. an HttpOnly cookie).

Notice the very different expiry windows. The access token is intentionally short so a leak has a tiny blast radius.

from datetime import datetime, timedelta, timezone

ACCESS_TOKEN_TTL = timedelta(minutes=15)
REFRESH_TOKEN_TTL = timedelta(days=7)

def expiry(ttl: timedelta) -> str:
    return (datetime.now(timezone.utc) + ttl).isoformat()

login_response = {
    "access_token": "<jwt>",
    "access_expires": expiry(ACCESS_TOKEN_TTL),
    "refresh_token": "<opaque-or-jwt>",
    "refresh_expires": expiry(REFRESH_TOKEN_TTL),
    "token_type": "bearer",
}

for k, v in login_response.items():
    print(f"{k}: {v}")

Signing an Access Token

Access tokens are JWTs signed with your secret. They carry a sub (user id), an exp claim, and a type claim so the server can refuse a refresh token where an access token is expected.

This snippet uses python-jose, the library most FastAPI tutorials rely on. It depends on an external package, so it is not standalone-runnable here.

from datetime import datetime, timedelta, timezone
from jose import jwt

SECRET = "change-me"
ALGO = "HS256"

def create_access_token(user_id: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "type": "access",
        "iat": now,
        "exp": now + timedelta(minutes=15),
    }
    return jwt.encode(payload, SECRET, algorithm=ALGO)

The Refresh Token Needs State

An access token is verified by signature alone — no database needed. A refresh token is different: to support revocation and rotation, the server must remember it.

The trick: never store the raw refresh token. Store only a hash of it, just like a password. If your DB leaks, the stored hashes cannot be replayed.

  • Generate a high-entropy random string as the token.
  • Hash it (SHA-256) and persist the hash, user id, expiry, and a revoked flag.
  • On refresh, hash the incoming token and look it up.
import hashlib, secrets

def new_refresh_token() -> tuple[str, str]:
    raw = secrets.token_urlsafe(48)          # give this to the client
    token_hash = hashlib.sha256(raw.encode()).hexdigest()  # store this
    return raw, token_hash

raw, stored = new_refresh_token()
print("client receives:", raw[:16], "...")
print("db stores hash :", stored[:16], "...")
print("lookup matches :", hashlib.sha256(raw.encode()).hexdigest() == stored)

What Is Token Rotation?

Rotation means every time a refresh token is used, it is consumed and replaced by a brand-new one. A refresh token is therefore single-use.

Sequence on each refresh:

  • Validate the presented refresh token (exists, not revoked, not expired).
  • Mark it revoked/used.
  • Issue a fresh access token and a fresh refresh token.
  • Return the new pair to the client.

Without rotation, a stolen refresh token works for its entire lifetime. With rotation, using it changes it — which is exactly what lets us detect theft.

Modeling the Stored Token

Here is a minimal in-memory model of the refresh-token store so you can see the moving parts before wiring a real database. Each record knows its owner, expiry, and whether it has been used or revoked.

In production this maps to a SQL table (with the hash as the key) or a Redis entry with a TTL.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

@dataclass
class RefreshRecord:
    token_hash: str
    user_id: str
    expires_at: datetime
    revoked: bool = False

    def is_valid(self, now: datetime) -> bool:
        return not self.revoked and now < self.expires_at

now = datetime.now(timezone.utc)
rec = RefreshRecord("abc123", "user-7", now + timedelta(days=7))
print("valid now      :", rec.is_valid(now))
rec.revoked = True
print("valid revoked  :", rec.is_valid(now))

The Rotation Logic

This is the heart of the lesson: a pure function that takes a presented refresh token, validates it, revokes it, and mints a replacement. No framework involved — just the algorithm.

Run it: the first refresh succeeds and returns a new token; reusing the old token afterward fails because it was rotated out.

import hashlib, secrets
from datetime import datetime, timedelta, timezone

store = {}  # token_hash -> {user_id, exp, revoked}

def _hash(raw): return hashlib.sha256(raw.encode()).hexdigest()

def issue(user_id):
    raw = secrets.token_urlsafe(32)
    store[_hash(raw)] = {
        "user_id": user_id,
        "exp": datetime.now(timezone.utc) + timedelta(days=7),
        "revoked": False,
    }
    return raw

def rotate(raw):
    rec = store.get(_hash(raw))
    now = datetime.now(timezone.utc)
    if not rec or rec["revoked"] or now >= rec["exp"]:
        raise ValueError("invalid refresh token")
    rec["revoked"] = True            # consume the old one
    return issue(rec["user_id"])     # mint a fresh one

old = issue("user-7")
new = rotate(old)
print("rotated to new token:", new[:12], "...")
try:
    rotate(old)
except ValueError as e:
    print("reuse of old token blocked:", e)

Detecting Token Theft via Reuse

Rotation gives a powerful security signal. If a refresh token that was already used shows up again, there are only two explanations:

  • The legitimate client never received the new token (rare), or
  • An attacker stole the old token and is replaying it.

Because you cannot tell which, the safe response is to treat reuse as a breach of the whole token family: revoke every refresh token for that user (or that session lineage) and force re-login.

This is called automatic reuse detection and is recommended by the OAuth2 Security Best Current Practice (RFC 9700).

Reuse Detection in Code

To detect reuse we keep a per-user family of tokens. A normal rotation revokes one token and adds its successor. If a token marked used=True is presented again, we nuke the entire family.

Run it to see a stolen-token replay trigger a full family wipe.

import secrets

family = {}  # token -> {"used": bool}

def issue():
    t = secrets.token_urlsafe(16)
    family[t] = {"used": False}
    return t

def rotate(t):
    rec = family.get(t)
    if rec is None:
        raise ValueError("unknown token")
    if rec["used"]:
        family.clear()  # reuse detected -> revoke whole family
        raise ValueError("REUSE DETECTED: all sessions revoked")
    rec["used"] = True
    return issue()

t1 = issue()
t2 = rotate(t1)      # normal rotation
print("rotation ok, new token issued")
try:
    rotate(t1)       # attacker replays the stolen old token
except ValueError as e:
    print(e)
print("tokens remaining:", len(family))

The FastAPI Refresh Endpoint

Now the FastAPI wiring. The refresh token arrives in the request body (or an HttpOnly cookie). The endpoint rotates it and returns a fresh pair.

Key choices visible here:

  • Reject anything that is not a refresh token type.
  • Return 401 on any validation failure — never leak why.
  • Always issue a new refresh token alongside the access token.

This depends on FastAPI and your store, so it is illustrative rather than standalone-runnable.

from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel

router = APIRouter()

class RefreshIn(BaseModel):
    refresh_token: str

class TokenPair(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"

@router.post("/auth/refresh", response_model=TokenPair)
async def refresh(body: RefreshIn):
    try:
        user_id = validate_and_consume(body.refresh_token)  # raises on reuse/expiry
    except ValueError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid refresh token",
        )
    return TokenPair(
        access_token=create_access_token(user_id),
        refresh_token=create_refresh_token(user_id),
    )

Storing and Revoking Server-Side

Server-side state is what makes revocation possible. A logout, a password change, or a detected breach should immediately invalidate refresh tokens.

Practical guidance:

  • Where: SQL table for durability, or Redis with a TTL equal to the token lifetime for speed and automatic expiry.
  • What to store: the SHA-256 hash, user id, expiry, a revoked flag, and optionally a family_id for reuse detection.
  • Logout: mark the presented token (and optionally its whole family) revoked.
  • Global logout: revoke all of a user's tokens — e.g. after a password reset.

Access tokens stay stateless and simply expire on their own within minutes, which is why short TTLs matter so much.

def logout(token_hash: str, conn) -> None:
    conn.execute(
        "UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = %s",
        (token_hash,),
    )

def revoke_all_for_user(user_id: str, conn) -> None:
    conn.execute(
        "UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s",
        (user_id,),
    )

Quick Check

Test your understanding of the core rotation decision.

Recap

You now know how to build secure session management with rotating refresh tokens:

  • Two tokens: short-lived stateless access tokens (5-15 min) plus long-lived refresh tokens tracked server-side.
  • Hash, never store raw: persist only the SHA-256 of the refresh token, like a password.
  • Rotation: every refresh consumes the old token (single-use) and issues a fresh pair.
  • Reuse detection: a replayed used token means likely theft — revoke the whole family and force re-login (RFC 9700).
  • Revocation: server-side state (SQL or Redis-with-TTL) lets logout, password change, and breach response invalidate tokens instantly.

The result: a stolen access token dies in minutes, a stolen refresh token is detected on first replay, and users still enjoy long-lived sessions.

자주 묻는 질문

“새로 고침 토큰과 토큰 순환” 강의는 무료인가요?

네 — “새로 고침 토큰과 토큰 순환” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 FastAPI Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

“새로 고침 토큰과 토큰 순환”에서 뭘 배우나요?

토큰 탈취를 완화하도록 수명이 짧은 액세스 토큰, 순환하는 새로 고침 토큰 및 서버 측 폐기를 설계합니다. 브라우저에서 직접 실행하는 실습 코드로 FastAPI Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

FastAPI Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 FastAPI Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.

“새로 고침 토큰과 토큰 순환” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 FastAPI Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 FastAPI Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. OAuth2 비밀번호 흐름과 토큰 발급
  2. python-jose를 활용한 JWT 서명과 검증
  3. 새로 고침 토큰과 토큰 순환
  4. 범위 기반 권한 부여와 역할 가드
← FastAPI Backend Development Bootcamp(으)로 돌아가기