[파이썬 100강] 59강. secrets로 예측 불가능한 토큰 안전하게 생성하기
로그인 링크, 비밀번호 재설정 URL, 이메일 인증 코드, 초대 토큰 같은 기능을 만들 때 가장 먼저 확인할 것은 "진짜 랜덤인가?"입니다. 이번 강의는 서론을 길게 끌지 않고, 바로 secrets로 안전한 토큰을 만들고 검증·만료·저장 정책까지 연결하는 흐름으로 갑니다.
핵심 개념
secrets모듈은 보안 용도(인증/토큰/시크릿 키)에 적합한 난수를 생성하기 위해 설계된 표준 라이브러리입니다.random모듈은 시뮬레이션/게임/샘플링에는 좋지만, 공격자가 예측하면 치명적인 보안 토큰에는 부적합합니다.- 토큰은 "생성"만으로 끝나지 않고, 만료 시간, 1회성 사용, 저장 방식(평문 금지) 까지 포함해야 운영에서 안전합니다.
많은 초보자가 "문자열 길이를 길게 만들면 안전하지 않나?"라고 생각합니다. 길이는 중요하지만, 더 중요한 건 랜덤 소스의 품질과 검증 정책입니다. 예를 들어 random.seed(42)로 만든 64자 문자열은 길어 보여도 재현 가능하기 때문에 인증 토큰으로는 무의미합니다. 반대로 secrets.token_urlsafe(32)는 URL에 넣기 쉬운 형태로 강한 엔트로피를 제공해 웹 서비스에서 바로 쓰기 좋습니다. 실무에서는 여기에 만료(예: 10분), 실패 횟수 제한, 사용 후 즉시 폐기를 함께 붙여야 실제 사고를 줄일 수 있습니다.
기본 사용
예제 1) 토큰 생성 기본: URL-safe 토큰 만들기
>>> import secrets
>>> token = secrets.token_urlsafe(32)
>>> token
'kOtJwTHzNcRDSY2aDxEx8A9vK8kR9xv71U2PPORR8rQ'
>>> len(token) >= 43 # 구현에 따라 길이는 달라질 수 있어 고정 길이 비교는 피함
True
해설:
token_urlsafe(nbytes)는 URL 파라미터, 이메일 링크, 문자메시지 링크에 넣기 좋은 문자열을 만듭니다.- 반환 길이는 인코딩 특성상 정확히 고정되지 않을 수 있으므로, "길이 딱 N" 검증 대신 최소 길이 기준을 두는 편이 안전합니다.
print(token)으로 로그에 남기면 유출이 되므로, 운영 로그에는 토큰 원문을 기록하지 않습니다.
예제 2) 숫자 인증코드(OTP 유사) 생성
>>> import secrets, string
>>> digits = string.digits
>>> code = ''.join(secrets.choice(digits) for _ in range(6))
>>> code
'502941'
>>> len(code), code.isdigit()
(6, True)
해설:
- 이메일/SMS 인증에 쓰는 6자리 코드는
secrets.choice로 생성해야 예측 가능성을 낮출 수 있습니다. - 인증코드는 사용자 경험 때문에 짧지만, 그만큼 만료 시간을 짧게(예: 3~5분) 가져가고 시도 횟수를 제한해야 합니다.
- 숫자 코드 재시도 정책(예: 5회 실패 시 잠금)을 반드시 같이 설계하세요.
예제 3) 토큰 검증 비교는 compare_digest로
>>> import secrets
>>> issued = secrets.token_urlsafe(24)
>>> user_input_good = issued
>>> user_input_bad = issued[:-1] + 'x'
>>> secrets.compare_digest(issued, user_input_good)
True
>>> secrets.compare_digest(issued, user_input_bad)
False
해설:
- 인증 토큰 비교는 단순
==보다secrets.compare_digest()가 권장됩니다. - 보안 비교는 "맞다/틀리다"뿐 아니라 비교 방식 자체도 공격 표면이 될 수 있다는 점을 기억하세요.
- 토큰을 DB에 저장할 때는 원문 대신 해시를 저장하고, 입력값 해시와 비교하는 구조가 기본입니다.
예제 4) 저장은 평문 대신 해시로
>>> import secrets, hashlib, time
>>> def issue_token_record(user_id: int, ttl_sec: int = 600):
... raw = secrets.token_urlsafe(32)
... digest = hashlib.sha256(raw.encode('utf-8')).hexdigest()
... return {
... 'user_id': user_id,
... 'token_hash': digest,
... 'expires_at': int(time.time()) + ttl_sec,
... 'used': False,
... }, raw
...
>>> rec, raw_token = issue_token_record(101)
>>> sorted(rec.keys())
['expires_at', 'token_hash', 'used', 'user_id']
>>> isinstance(raw_token, str) and rec['token_hash'] != raw_token
True
해설:
- 사용자에게 전달하는 건
raw_token이고, 서버는token_hash만 저장합니다. - DB가 유출돼도 평문 토큰이 없으면 즉시 악용 난이도가 올라갑니다.
- 해시 알고리즘은 최소 SHA-256 이상을 사용하고, 토큰 길이와 만료 정책을 함께 관리하세요.
자주 하는 실수
실수 1) random으로 인증 토큰 만들기
>>> import random, string
>>> random.seed(1234)
>>> alphabet = string.ascii_letters + string.digits
>>> t1 = ''.join(random.choice(alphabet) for _ in range(16))
>>> random.seed(1234)
>>> t2 = ''.join(random.choice(alphabet) for _ in range(16))
>>> t1 == t2
True
원인:
random은 재현 가능한 의사난수라서, 시드가 노출되거나 추정되면 토큰 예측 위험이 생깁니다.
해결:
>>> import secrets, string
>>> alphabet = string.ascii_letters + string.digits
>>> ''.join(secrets.choice(alphabet) for _ in range(16)) # 매번 독립 난수
'Qk8vE2L1mP0sTx9a'
실수 2) 토큰을 서버 로그에 그대로 기록
- 증상: 문제 분석은 쉬워지지만, 로그 저장소 접근자에게 인증 링크 재사용 위험이 생깁니다.
- 원인: 디버깅 편의를 위해
print(token)또는 구조화 로그에token필드를 그대로 남김. - 해결: 로그에는 토큰 앞 4자리만 남기거나 아예 마스킹(
ab12****) 처리하고, 원문은 저장하지 않습니다.
실수 3) 만료 없는 토큰 발급
>>> token_store = {'abc': {'user_id': 7, 'expires_at': None}}
>>> token_store['abc']['expires_at'] is None
True
원인:
- "나중에 지우면 되지"라는 생각으로 만료 정책 없이 발급하면, 오래된 링크가 공격 표면으로 남습니다.
해결:
>>> import time
>>> token_store = {'abc': {'user_id': 7, 'expires_at': int(time.time()) + 600}}
>>> token_store['abc']['expires_at'] > int(time.time())
True
실수 4) 1회성 토큰을 여러 번 사용 가능하게 둠
- 증상: 같은 비밀번호 재설정 링크를 여러 번 재사용할 수 있습니다.
- 원인: 검증 후
used=True업데이트를 안 하거나, 트랜잭션 없이 처리해 경쟁 조건이 발생합니다. - 해결: 검증 성공 시 즉시 사용 처리(원자적 업데이트)하고, 이미 사용된 토큰은 무조건 거부합니다.
실무 패턴
- 입력 검증 규칙: 토큰 형식(길이/허용 문자)을 먼저 확인하고, 형식이 비정상이면 DB 조회 전에 바로 거부해 비용과 공격 노출을 줄입니다.
- 로그/예외 처리 규칙: 실패 로그에는
user_id,request_id,reason(expired|not_found|used)만 남기고 토큰 원문은 기록하지 않습니다. - 재사용 함수/구조화 팁:
issue_token(),store_token_hash(),verify_and_consume_token()3단으로 분리하면 웹/API/배치에서 공통 사용이 쉽습니다. - 성능/메모리 체크 포인트: 토큰 검증 테이블은 만료 인덱스(
expires_at)를 두고 주기적으로 정리해야 조회 성능이 유지됩니다.
실무에서 특히 중요한 건 "검증 실패 이유를 사용자에게 얼마나 노출할지"입니다. 사용자 메시지는 보통 "유효하지 않거나 만료된 링크입니다"처럼 통합하고, 내부 로그에서만 상세 사유를 구분합니다. 이렇게 해야 계정 존재 여부나 토큰 유효성 힌트를 공격자에게 덜 주게 됩니다. 또 모바일/웹 동시 로그인 환경에서는 동일 토큰 다중 사용 경합이 흔하므로, 검증과 사용 처리(consumption)를 하나의 원자적 쿼리로 묶는 설계가 안전합니다. 마지막으로 보안 사고 대응을 위해 토큰 발급·검증 이벤트를 최소 메타데이터로 감사 가능하게 남겨야 합니다.
오늘의 결론
한 줄 요약: 보안 토큰은 secrets로 생성하고, 만료·1회성·해시 저장 정책까지 함께 설계해야 안전합니다.
기억할 것:
random은 보안 토큰용이 아니라 일반 난수용입니다.- 토큰 비교는
compare_digest, 저장은 평문이 아닌 해시가 기본입니다. - 짧은 만료 시간 + 실패 횟수 제한 + 사용 후 폐기가 실제 방어력을 만듭니다.
연습문제
issue_reset_token(user_id)함수를 작성해(raw_token, token_hash, expires_at)를 반환하세요. 만료 시간은 현재 시각 기준 10분으로 설정하세요.verify_token(raw_token, token_hash, expires_at, now)함수를 작성해 만료 여부와 해시 일치 여부를 함께 검증하세요.- "1회성" 요구사항을 만족하도록
used플래그를 포함한 토큰 소비 함수consume_token(record, raw_token, now)를 설계하세요. 성공 시used=True로 바뀌어야 합니다.
이전 강의 정답
verify_webhook(secret, raw_body, received_signature)구현
>>> import hmac, hashlib, re
>>> def verify_webhook(secret: bytes, raw_body: bytes, received_signature: str) -> bool:
... if not re.fullmatch(r"sha256=[0-9a-f]{64}", received_signature):
... return False
... expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
... return hmac.compare_digest(expected, received_signature)
...
>>> s = b'k'; body = b'{"ok":true}'
>>> good = 'sha256=' + hmac.new(s, body, hashlib.sha256).hexdigest()
>>> verify_webhook(s, body, good), verify_webhook(s, body, 'sha256=xyz')
(True, False)
- 타임스탬프 결합 + 300초 검증
>>> import time, hmac, hashlib
>>> def verify_with_ts(secret: bytes, ts: int, body: bytes, sig: str, now: int, window: int = 300) -> bool:
... if abs(now - ts) > window:
... return False
... payload = f"{ts}.".encode() + body
... expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
... return hmac.compare_digest(expected, sig)
...
>>> secret = b'k'; ts = int(time.time()); body = b'x=1'
>>> sig = hmac.new(secret, f"{ts}.".encode()+body, hashlib.sha256).hexdigest()
>>> verify_with_ts(secret, ts, body, sig, now=ts+120)
True
- current/previous 키 병행 검증 + 유예기간
>>> import time, hmac, hashlib
>>> def verify_with_rotation(current_key: bytes, previous_key: bytes | None, prev_valid_until: int, payload: bytes, sig: str, now: int) -> bool:
... cur = hmac.new(current_key, payload, hashlib.sha256).hexdigest()
... if hmac.compare_digest(cur, sig):
... return True
... if previous_key and now <= prev_valid_until:
... old = hmac.new(previous_key, payload, hashlib.sha256).hexdigest()
... return hmac.compare_digest(old, sig)
... return False
...
>>> now = int(time.time())
>>> payload = b'event=pay'
>>> cur, prev = b'cur-key', b'old-key'
>>> sig_prev = hmac.new(prev, payload, hashlib.sha256).hexdigest()
>>> verify_with_rotation(cur, prev, now+86400, payload, sig_prev, now)
True
>>> verify_with_rotation(cur, prev, now-1, payload, sig_prev, now)
False
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
secrets,hashlib,time,string - 재현 절차:
- 기본 사용 예제 1~2로 토큰/코드 생성 패턴을 먼저 실행합니다.
- 예제 3으로
compare_digest비교를 확인합니다. - 예제 4로 해시 저장 구조를 만든 뒤, 원문 토큰을 저장하지 않는지 점검합니다.
- 실수 섹션을 순서대로 재현해 취약 패턴이 왜 위험한지 확인합니다.
- 검증 포인트:
- 보안 토큰 생성이
random이 아니라secrets기반인지 - 토큰이 만료/1회성 정책을 가지는지
- 저장 데이터에 토큰 원문이 남지 않는지
- 보안 토큰 생성이