웹 API 보안: 인증·인가 실전 가이드
출처: Dev.to
웹 API 보안: 인증 및 인가 방법에 대한 실용 가이드
대부분의 API 보안 사고는 공격자가 교묘한 제로데이 취약점을 발견했기 때문에 일어나는 것이 아니다. 개발자가 떠오른 첫 번째 인증 패턴을 잡아 구현하고, 배포하고, 넘어갔기 때문에 발생한다.
공개 저장소에 커밋된 API 키, 만료되지 않은 JWT가 프로덕션에 그대로 사용되는 경우, 모바일 클라이언트에서 PKCE를 생략한 OAuth 흐름 등을 본 적이 있다. 이런 실수들은 특이한 것이 아니라, 엔지니어가 각 방법이 무엇을 하는지, 언제 적용해야 하는지, 어디서 한계가 있는지를 명확히 파악하지 못했을 때 기본적으로 나타나는 결과다.
이 가이드는 그런 지도를 제공한다. 오늘날 웹 API를 보호하는 데 사용되는 주요 인증·인가 방법을 모두 다루고, 파이썬 코드 예시와 마지막에 제공되는 의사결정 매트릭스를 통해 상황에 맞는 도구를 선택할 수 있도록 돕는다.
두 단어는 자주 혼용된다. 이는 문제이며, 혼동은 실제 취약점으로 이어진다.
- 인증(Authentication) 은 “당신은 누구인가?”에 답한다. 신원을 검증한다.
- 인가(Authorization) 은 “당신은 무엇을 할 수 있는가?”에 답한다. 권한을 강제한다.
요청은 인증될 수 있지만(우리는 사용자 A임을 안다) 인가되지 않을 수 있다(사용자 A는 해당 리소스에 접근 권한이 없다). “이 토큰이 유효한가?”만 확인하고 “이 토큰이 해당 작업을 수행할 권한이 있는가?”를 확인하지 않는 시스템은 권한 상승에 취약하다.
각 방법을 살펴볼 때 이 점을 항상 염두에 두자.
API 키
API 키는 서버가 생성한 길고 무작위인 문자열이며 클라이언트와 공유된다. 클라이언트는 보통 헤더에 담아 모든 요청에 포함한다.
GET /v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_a3f8c2d1e9b7...
서버에서는 저장소와 대조해 검증한다.
import secrets
from functools import wraps
from flask import Flask, request, jsonify
app = Flask(__name__)
# 실제 운영에서는 메타데이터(소유자, 스코프, 레이트 제한, 생성 시점, 마지막 사용 시점)를 DB에 저장한다.
VALID_KEYS = {
"sk_live_a3f8c2d1e9b7abc123": {"owner": "client_A", "scopes": ["read"]},
"sk_live_z9y8x7w6v5u4t3s2r1": {"owner": "client_B", "scopes": ["read", "write"]},
}
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
key = request.headers.get("X-API-Key")
if not key or key not in VALID_KEYS:
return jsonify({"error": "Invalid or missing API key"}), 401
request.api_client = VALID_KEYS[key]
return f(*args, **kwargs)
return decorated
@app.route("/v1/data")
@require_api_key
def get_data():
return jsonify({"message": f"Hello, {request.api_client['owner']}"})
API 키는 클라이언트가 알려진 시스템(엔드 유저가 아닌)인 서버‑간 통신에 적합하며, 특히 공개 API에서 소비자별 사용량을 추적하고 레이트 제한이나 티어 접근을 관리하고 싶을 때 유용하다. 내부 도구가 간단히 필요하고 OAuth 2.0이 과도하게 복잡할 경우에도 좋은 선택이다.
- 기본적으로 만료되지 않는다 → 유출된 키는 수동으로 교체하기 전까지 유효하다.
- 사용자 신원을 담고 있지 않고 클라이언트 신원만을 나타낸다.
- 절대 URL에 포함시키지 말 것(서버 로그에 남는다). 항상 헤더를 사용한다.
- 키 비교 시
==대신secrets.compare_digest()를 사용해 타이밍 공격을 방지한다.
Basic Auth
클라이언트는 username:password를 Base64로 인코딩해 Authorization 헤더에 담아 매 요청마다 전송한다.
GET /v1/resource HTTP/1.1
Authorization: Basic c2hvdW1pazpteXBhc3N3b3Jk
import base64
from flask import request, jsonify
def check_basic_auth():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Basic "):
return None
try:
decoded = base64.b64decode(auth[6:]).decode("utf-8")
username, password = decoded.split(":", 1)
return username, password
except Exception:
return None
Basic Auth는 내부 도구, VPN 뒤에 있는 관리자 대시보드, 혹은 간단한 스크립트가 내부 API를 호출할 때 적합하다. 반드시 HTTPS 위에서 사용해야 한다—Base64는 인코딩일 뿐 암호화가 아니며, 평문 HTTP에서는 자격 증명이 쉽게 복원된다.
- 모든 요청에 자격 증명이 포함되므로 한 번 가로채이면 자격 증명이 유출된다.
- 토큰 만료, 스코프, 비밀번호 변경 없이 토큰을 폐기할 방법이 없다.
- 외부에 노출되는 API나 사용자‑대면 API에서는 절대 사용하지 말아야 한다. 모바일·브라우저 클라이언트와는 맞지 않는다.
JWT
JWT는 자체 포함된 서명 토큰으로, 세 개의 Base64URL‑인코딩 파트(헤더, 페이로드, 서명)로 구성된다. 로그인 시 서버가 서명하고, 이후 클라이언트는 매 요청마다 토큰을 전송한다. 서버는 데이터베이스를 조회하지 않고 서명만 검증하면 된다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJ1c2VyX2lkIjoiMTIzIiwiZXhwIjoxNzE... ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_... ← signature
import jwt
import datetime
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = "use-a-strong-secret-from-env-not-hardcoded"
def generate_token(user_id: str) -> str:
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
"sub": user_id,
"iat": now,
"exp": now + datetime.timedelta(hours=1),
"scopes": ["read", "write"],
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def verify_token(token: str) -> dict:
try:
# 알고리즘은 반드시 명시한다—alg=None 절대 사용 금지
return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
except jwt.InvalidTokenError:
raise ValueError("Invalid token")
@app.route("/login", methods=["POST"])
def login():
# 여기서 자격 증명을 검증한다(생략)
token = generate_token(user_id="user_123")
return jsonify({"access_token": token, "token_type": "Bearer"})
@app.route("/v1/profile")
def profile():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "Missing token"}), 401
try:
claims = verify_token(auth[7:])
return jsonify({"user_id": claims["sub"]})
except ValueError as e:
return jsonify({"error": str(e)}), 401
JWT는 무상태(stateless) 분산 시스템—마이크로서비스, CDN 뒤 API, 서버 간 세션을 공유할 수 없는 환경—에 최적이다. 싱글 페이지 애플리케이션과 모바일 클라이언트가 한 번 인증하고 여러 서비스에 걸쳐 클레임을 전달해야 할 때 표준으로 사용된다.
흔히 발생하는 실수와 해결책
| 실수 | 결과 | 해결 방법 |
|---|---|---|
alg: none 허용 | 누구든 유효한 토큰을 위조할 수 있다 | algorithms=["HS256"]와 같이 알고리즘을 명시적으로 지정한다 |
exp 클레임 누락 (만료 없음) | 유출된 토큰이 영원히 유효 | 반드시 exp를 설정한다; 액세스 토큰은 15 |