웹 API 보안: 인증·인가 실전 가이드

발행: (2026년 5월 25일 AM 06:20 GMT+9)
9 분 소요
원문: Dev.to

출처: 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
0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.