🛡️ Flask API 서버: HttpOnly와 CSRF 토큰으로 XSS/CSRF 동시에 방어하기
React와 같은 프론트엔드와 연동되는 Flask API 서버에서 가장 흔한 두 가지 웹 보안 위협, XSS(Cross-Site Scripting)와 CSRF(Cross-Site Request Forgery)를 어떻게 효과적으로 방어하는지 코드와 함께 설명합니다.
우리가 살펴본 Flask 코드는 'Double Submit Cookie' 또는 'Synchronizer Token Pattern'을 기반으로 하며, 강력한 쿠키 옵션을 활용하여 보안을 구축합니다.
1. 🔑 XSS 방어의 핵심: HttpOnly 쿠키
XSS 공격의 주요 목적은 사용자 브라우저에 삽입된 악성 JavaScript를 통해 세션 쿠키를 탈취하는 것입니다. Flask는 httponly=True 옵션으로 이를 원천 차단합니다.
| 코드 속성 |
설명 |
방어 원리 |
| httponly=True |
세션 ID가 담긴 쿠키를 설정할 때 사용됩니다. |
이 옵션이 설정되면, JavaScript 코드(XSS 스크립트 포함)는 쿠키 값에 접근하거나 읽을 수 없습니다. 악성 스크립트가 실행되어도 세션 ID를 탈취할 수 없어 XSS 공격의 가장 치명적인 피해를 막습니다. |
| secure=True |
쿠키는 HTTPS 연결에서만 전송됩니다. |
중간자 공격(MITM)을 통해 쿠키가 네트워크 상에서 노출되는 것을 방지합니다. 배포 환경에서는 필수입니다. |
2. 🛡️ CSRF 방어의 핵심: CSRF 토큰 검증
CSRF 공격은 사용자가 로그인된 상태에서 외부 악성 사이트가 사용자의 권한을 이용해 서버에 요청을 보내는 것을 목표로 합니다. Flask 코드에서는 데코레이터와 두 가지 쿠키를 사용하여 이를 방어합니다.
A. 두 종류의 토큰 발행 (/api/login 엔드포인트)
로그인 성공 시, 서버는 두 가지 쿠키를 발행합니다.
| 쿠키 이름 |
session_id |
XSRF-TOKEN |
| HttpOnly? |
✅ 예 (httponly=True) |
❌ 아니오 |
| 목적 |
서버에서 사용자를 식별 |
클라이언트(React)가 읽어 요청 헤더에 담을 토큰 |
| 방어 대상 |
XSS (세션 탈취) |
CSRF (요청 위조) |
B. csrf_protect 데코레이터 검증 (/api/logout 및 POST/PUT 엔드포인트)
상태를 변경하는 모든 API 요청은 @csrf_protect 데코레이터를 거쳐야 합니다.
| 검증 단계 |
설명 |
| 1. 서버 저장 토큰 확인 |
서버는 현재 세션 ID(session_id)에 연결된 진짜 CSRF 토큰을 메모리(또는 DB)에서 가져옵니다. |
| 2. 클라이언트 전송 토큰 확인 |
클라이언트(React)가 HTTP 헤더(X-CSRF-TOKEN)에 담아 보낸 토큰을 받습니다. (클라이언트는 XSRF-TOKEN 쿠키 값을 읽어 헤더에 넣어 보냄) |
| 3. 토큰 일치 확인 |
헤더 토큰과 서버 저장 토큰이 정확히 일치하는지 확인합니다. 일치하지 않으면 403 Forbidden 에러로 응답하여 위조된 요청을 차단합니다. |
C. 보조 방어막: SameSite 속성
samesite='Lax' 옵션은 상태 변경 요청(POST 등)에 대해 크로스 사이트 환경에서 쿠키가 전송되는 것을 제한하여 CSRF 방어의 보조적인 역할을 수행합니다.
3. 🚨 최종 결론: 안전한 API 서버 구축
이 Flask 코드는 세션 관리의 보안 모범 사례를 따르고 있습니다. XSS 방어를 위해 세션 ID는 JavaScript가 접근할 수 없도록 격리하고, CSRF 방어를 위해 요청마다 일회성 토큰을 검증함으로써 안전하고 견고한 API 서버 환경을 구축합니다.
💡 다음 단계: 이 Flask 서버와 연동하기 위해 React 클라이언트에서 XSRF-TOKEN 쿠키 값을 읽어 X-CSRF-TOKEN 헤더에 자동으로 추가하는 axios 인터셉터 구현이 필요합니다.
from flask import Flask, request, jsonify, make_response
from flask_cors import CORS
import secrets
from functools import wraps
import os
# =========================================================
# 💡 [핵심 보안] HttpOnly + SameSite + CSRF 토큰 패턴을 사용한 방어
# =========================================================
# 실제 환경에서는 데이터베이스를 사용해야 하지만, 여기서는 간단한 딕셔너리로 대체합니다.
# 세션 구조: {session_id: {'user_id': str, 'csrf_token': str}}
ACTIVE_SESSIONS = {}
USER_DB = {"user1": "pass123"}
app = Flask(__name__)
# ⭐ 1. 상수 정의
FRONTEND_URL = "http://localhost:3000"
SESSION_COOKIE_NAME = 'session_id'
CSRF_COOKIE_NAME = 'XSRF-TOKEN' # 클라이언트(React)가 읽을 토큰 쿠키 이름
CSRF_HEADER_NAME = 'X-CSRF-TOKEN' # 클라이언트가 토큰을 담아 보낼 헤더 이름
# CORS 설정: 프론트엔드 도메인 지정 및 쿠키 전송 허용
CORS(app, resources={r"/*": {"origins": FRONTEND_URL, "supports_credentials": True}})
def generate_safe_token():
"""암호학적으로 안전한 무작위 토큰을 생성합니다."""
return secrets.token_urlsafe(32)
# ---------------------------------------------------------
# ⭐ 2. CSRF 보호 데코레이터
# ---------------------------------------------------------
def csrf_protect(f):
"""CSRF 토큰 검증 로직을 수행하는 데코레이터"""
@wraps(f)
def decorated_function(*args, **kwargs):
session_id = request.cookies.get(SESSION_COOKIE_NAME)
csrf_header_token = request.headers.get(CSRF_HEADER_NAME)
# 1. 세션 존재 및 토큰 유효성 검사
if not session_id or session_id not in ACTIVE_SESSIONS:
return jsonify({"message": "인증되지 않았습니다 (세션 없음)."}), 401
session_data = ACTIVE_SESSIONS[session_id]
expected_csrf_token = session_data.get('csrf_token')
# 2. 헤더 토큰과 서버 저장 토큰 비교
if not csrf_header_token or csrf_header_token != expected_csrf_token:
return jsonify({"message": "CSRF 토큰 검증 실패: 유효하지 않거나 누락됨."}), 403
# 검증 성공 시 함수 실행
return f(*args, **kwargs)
return decorated_function
## 🔑 로그인 및 세션/토큰 생성 엔드포인트
@app.route('/api/login', methods=['POST'])
def login():
data = request.json
username = data.get('username')
password = data.get('password')
if USER_DB.get(username) != password:
return jsonify({"message": "로그인 실패"}), 401
# 세션 ID 및 CSRF 토큰 생성
new_session_id = generate_safe_token()
new_csrf_token = generate_safe_token()
# 3. 세션 정보 저장 (세션 ID와 CSRF 토큰을 연결)
ACTIVE_SESSIONS[new_session_id] = {
'user_id': username,
'csrf_token': new_csrf_token
}
response = make_response(jsonify({"message": "로그인 성공!", "username": username}), 200)
# 4. HttpOnly 세션 쿠키 설정 (XSS 방어)
response.set_cookie(
SESSION_COOKIE_NAME,
new_session_id,
max_age=60 * 60 * 24 * 7,
secure=True,
httponly=True, # JS 접근 차단 (XSS 방어)
samesite='Lax' # CSRF 방어 보조 수단
)
# 5. 일반 CSRF 토큰 쿠키 설정 (JS가 읽어야 하므로 HttpOnly=False)
response.set_cookie(
CSRF_COOKIE_NAME,
new_csrf_token,
max_age=60 * 60 * 24 * 7,
secure=True,
# httponly=False (기본값) : JS가 읽도록 허용
samesite='Lax'
)
return response
## 🔒 보호된 라우트 (GET 요청은 CSRF 방어 필요 없음)
@app.route('/api/profile', methods=['GET'])
def profile():
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id or session_id not in ACTIVE_SESSIONS:
return jsonify({"message": "인증되지 않은 사용자입니다."}), 401
user_id = ACTIVE_SESSIONS[session_id]['user_id']
return jsonify({
"message": "환영합니다!",
"user_id": user_id,
"data": "보안이 필요한 사용자 프로필 정보"
})
## 🚪 로그아웃 (상태 변경 요청이므로 CSRF 보호 적용)
@app.route('/api/logout', methods=['POST'])
@csrf_protect # ⭐ CSRF 토큰 검증 데코레이터 적용
def logout():
session_id = request.cookies.get(SESSION_COOKIE_NAME)
# 세션 데이터베이스에서 세션 ID 무효화/삭제
if session_id in ACTIVE_SESSIONS:
del ACTIVE_SESSIONS[session_id]
# 쿠키 삭제 (세션 쿠키와 CSRF 토큰 쿠키 모두 삭제)
response = make_response(jsonify({"message": "로그아웃 완료"}), 200)
# 세션 쿠키 삭제 (HttpOnly)
response.set_cookie(SESSION_COOKIE_NAME, '', max_age=0, secure=True, httponly=True, samesite='Lax')
# CSRF 토큰 쿠키 삭제 (Non-HttpOnly)
response.set_cookie(CSRF_COOKIE_NAME, '', max_age=0, secure=True, samesite='Lax')
return response
# ---------------------------------------------------------
# 💡 참고: POST/PUT/DELETE 등 모든 상태 변경 라우트에 @csrf_protect를 적용해야 합니다.
# 예: @app.route('/api/update', methods=['POST'])
# ---------------------------------------------------------
if __name__ == '__main__':
app.run(port=5000)
💻 React 클라이언트 연동: CSRF 토큰을 자동으로 처리하여 보안 완성하기
위에서 Flask 백엔드를 HttpOnly 쿠키와 CSRF 토큰 검증 시스템으로 안전하게 구축했습니다. 이제 이 서버와 통신하는 React 클라이언트에서 보안 정책을 어떻게 준수하고 CSRF 토큰을 처리하는지 자세히 알아보겠습니다.
React 코드의 핵심은 axios 라이브러리의 인터셉터를 사용하여 CSRF 토큰을 자동으로 읽어 HTTP 헤더에 담아 전송하는 데 있습니다.
1. ⚙️ React 코드 개요
이 React 컴포넌트는 로그인 시 서버로부터 session_id (HttpOnly)와 XSRF-TOKEN (일반 쿠키)을 받고, 상태 변경 요청(로그아웃 등) 시 CSRF 토큰을 헤더에 첨부하는 역할을 합니다.
2. 🔑 핵심 포인트: React 클라이언트의 보안 연동
클라이언트 코드에서 가장 중요한 역할은 Flask 서버의 CSRF 방어 시스템을 무너뜨리지 않고 정상적으로 요청을 완료하는 것입니다.
A. withCredentials: true 설정
| 코드 |
withCredentials: true |
| 목적 |
크로스 도메인 요청 시 쿠키 전송 허용 |
| 원리 |
React 앱(예: localhost:3000)이 Flask 서버(예: localhost:5000)로 API 요청을 보낼 때, 브라우저가 **인증 정보(쿠키)**를 요청에 자동으로 첨부하도록 지시합니다. 이 설정이 없으면 Flask 서버가 쿠키를 받지 못해 인증이 실패합니다. |
B. session_id와 XSRF-TOKEN 처리 방식
React 클라이언트는 서버로부터 받은 두 종류의 쿠키를 다르게 처리합니다.
| 쿠키 이름 |
특징 |
React의 처리 |
| session_id |
HttpOnly |
접근 불가. XSS 방어를 위해 JavaScript가 읽을 수 없으며, 브라우저가 서버에 자동으로 전송만 해줍니다. |
| XSRF-TOKEN |
Non-HttpOnly |
접근 가능. CSRF 방어를 위해 JavaScript가 쿠키 값을 읽어 요청 헤더에 수동으로 첨부합니다. |
C. ⭐ 핵심 로직: Axios 요청 인터셉터
클라이언트 코드의 심장부입니다. 상태를 변경하는 모든 요청에 대해 CSRF 토큰을 자동으로 헤더에 넣어 서버가 검증할 수 있도록 합니다.
- 쿠키 읽기: getCookie('XSRF-TOKEN') 함수를 사용하여 일반 쿠키인 XSRF-TOKEN 값을 읽어옵니다.
- 메서드 필터링: POST, PUT, DELETE, PATCH 등 상태를 변경하는 요청에 대해서만 토큰 첨부 로직을 실행합니다. (GET 요청은 CSRF 공격 대상이 아니므로 제외)
- 헤더 설정: 읽어온 토큰 값을 Flask 서버가 기대하는 헤더 이름인 **X-CSRF-TOKEN**에 넣어 요청을 전송합니다.
CSRF 방어 성공: 외부 악성 사이트가 보낸 요청은 XSRF-TOKEN 쿠키를 읽을 수 없기 때문에 헤더에 올바른 X-CSRF-TOKEN 값을 담지 못하며, Flask 서버는 이를 감지하고 403 에러로 차단합니다.
이로써 프론트엔드와 백엔드가 모두 보안 모범 사례를 따르는 완벽한 인증 시스템이 완성됩니다.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
// =========================================================
// 💡 클라이언트 측 CSRF 토큰 처리 로직 (HttpOnly 쿠키는 접근 불가)
// =========================================================
// CSRF 토큰 관련 상수 (Flask 서버 설정과 일치해야 함)
const CSRF_COOKIE_NAME = 'XSRF-TOKEN';
const CSRF_HEADER_NAME = 'X-CSRF-TOKEN';
const FLASK_API_URL = 'http://localhost:5000/api'; // Flask 서버 주소
// ---------------------------------------------------------
// 1. 쿠키에서 특정 값을 읽어오는 헬퍼 함수
// ---------------------------------------------------------
const getCookie = (name) => {
if (typeof document === 'undefined') return null; // 서버 환경 체크
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
};
// ---------------------------------------------------------
// 2. Axios 인스턴스 생성 및 인터셉터 설정
// ---------------------------------------------------------
const api = axios.create({
baseURL: FLASK_API_URL,
withCredentials: true, // ⭐ 쿠키를 포함하여 요청하도록 브라우저에 지시 (CORS 설정 필수)
});
// ⭐ 요청 인터셉터: 모든 요청이 서버로 가기 전에 CSRF 토큰을 헤더에 추가
api.interceptors.request.use(config => {
// POST, PUT, DELETE, PATCH 등 상태를 변경하는 메서드에만 적용
const isMutatingMethod = ['post', 'put', 'delete', 'patch'].includes(config.method);
if (isMutatingMethod) {
const csrfToken = getCookie(CSRF_COOKIE_NAME);
if (csrfToken) {
// 💡 Flask 서버가 요구하는 헤더 이름에 토큰 값을 설정
config.headers[CSRF_HEADER_NAME] = csrfToken;
} else {
// 토큰이 없는 경우 (예: 세션 만료, 로그인 전) 경고
console.warn('CSRF 토큰을 찾을 수 없습니다. 로그인 상태를 확인하세요.');
}
}
return config;
}, error => {
return Promise.reject(error);
});
// ---------------------------------------------------------
// 3. React 컴포넌트
// ---------------------------------------------------------
const App = () => {
const [username, setUsername] = useState('user1');
const [password, setPassword] = useState('pass123');
const [message, setMessage] = useState('로그인 정보(user1/pass123)를 입력하세요.');
// Tailwind CSS를 사용한 기본 스타일
const buttonClass = "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition duration-150 shadow-md";
const inputClass = "p-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500";
const handleLogin = async (e) => {
e.preventDefault();
setMessage('로그인 시도 중...');
try {
const response = await api.post('/login', { username, password });
// 로그인 성공 후 브라우저가 HttpOnly 세션 쿠키와 XSRF-TOKEN 쿠키를 저장합니다.
setMessage(`로그인 성공! ${response.data.username}님 환영합니다. 이제 쿠키가 설정되었습니다.`);
} catch (error) {
setMessage(`로그인 실패: ${error.response?.data?.message || '서버 오류'}`);
}
};
const fetchProfile = async () => {
setMessage('프로필 정보 요청 중...');
try {
// GET 요청은 CSRF 토큰을 보내지 않아도 됩니다. (브라우저가 session_id 쿠키만 자동으로 첨부)
const response = await api.get('/profile');
setMessage(`프로필 로드 성공: ${response.data.data}`);
} catch (error) {
setMessage(`프로필 로드 실패: ${error.response?.data?.message || '인증 실패'}`);
}
};
const handleLogout = async () => {
setMessage('로그아웃 요청 중...');
try {
// ⭐ POST 요청이므로 인터셉터가 X-CSRF-TOKEN 헤더를 자동으로 첨부합니다.
// Flask 서버는 이 헤더의 토큰을 검증합니다.
await api.post('/logout');
setMessage('로그아웃 완료. 모든 쿠키가 삭제되었습니다.');
} catch (error) {
// CSRF 토큰이 없거나 유효하지 않으면 여기서 403 Forbidden 에러가 발생합니다.
setMessage(`로그아웃 실패: ${error.response?.data?.message || 'CSRF 검증 오류'}`);
}
};
return (
<div className="p-8 max-w-lg mx-auto bg-white shadow-xl rounded-xl mt-10 font-sans">
<h2 className="text-3xl font-bold mb-6 text-gray-800 border-b pb-2">
React 보안 세션 클라이언트
</h2>
<p className="text-sm text-gray-600 mb-6">
이 클라이언트는 HttpOnly 세션(XSS 방어)과 CSRF 토큰(CSRF 방어)을 모두 처리합니다.
</p>
{/* 로그인 폼 */}
<form onSubmit={handleLogin} className="flex flex-col space-y-4 mb-8">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="사용자 이름 (user1)"
className={inputClass}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호 (pass123)"
className={inputClass}
/>
<button type="submit" className={buttonClass}>
로그인 (세션 및 토큰 생성)
</button>
</form>
{/* 동작 버튼 */}
<div className="flex flex-col space-y-3">
<button onClick={fetchProfile} className={`${buttonClass} bg-green-500 hover:bg-green-600`}>
GET: 프로필 정보 보기 (인증 확인)
</button>
<button onClick={handleLogout} className={`${buttonClass} bg-red-500 hover:bg-red-600`}>
POST: 로그아웃 요청 (CSRF 토큰 자동 첨부)
</button>
</div>
{/* 상태 메시지 */}
<div className="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h3 className="font-semibold text-lg mb-2 text-gray-700">현재 상태</h3>
<p className="text-gray-900 break-words">{message}</p>
<p className="text-xs text-gray-500 mt-2">
**주의:** Flask 서버(http://localhost:5000)를 먼저 실행해야 합니다.
</p>
</div>
</div>
);
};
export default App;