JWT 구성요소
JWT는 세 부분으로 구성됩니다:
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
각 부분은 Base64Url로 인코딩된 JSON 문자열입니다.
1. Header (헤더)
토큰의 타입과 서명 알고리즘 정보를 담습니다.
{
"alg": "HS256",
"typ": "JWT"
}
- alg: 서명에 사용할 알고리즘 (예: HS256, RS256)
- typ: 토큰 타입 (보통 "JWT")
2. Payload (페이로드, = claims)
사용자 정보와 토큰의 의미 있는 데이터를 담는 핵심 부분입니다.
{
"sub": "accessToken",
"userId": 123,
"email": "user@example.com",
"role": "ADMIN",
"iat": 1718780000,
"exp": 1718783600
}
여기서 각각은 claim입니다.
Claim의 종류
- Registered claims (표준 클레임)
- sub: Subject (주제)
- iss: Issuer (발급자)
- aud: Audience (대상)
- exp: Expiration (만료 시간)
- nbf: Not Before (활성 시작 시간)
- iat: Issued At (발급 시각)
- jti: JWT ID (토큰 고유 ID)
- Public claims (공개 클레임)
- 예: email, role 등. 사용자가 정한 이름이지만 충돌 방지를 위해 URI 명시 권장
- Private claims (사설 클레임)
- 조직 내부에서만 사용하는 커스텀 정보들. 예: userId, teamId, companyCode
3. Signature (서명)
토큰이 변조되지 않았는지 검증하기 위한 HMAC 또는 RSA 서명 값
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
- 클라이언트는 signature를 해석할 수 없고, 서버는 이를 통해 무결성 검증을 합니다.
사용 예시 (Spring 기준)
JWT 발급 시 Claims 설정 예
String token = Jwts.builder()
.setSubject("accessToken")
.claim("userId", 123)
.claim("role", "ADMIN")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000)) // 1시간
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
JWT 파싱 시 Claims 사용 예
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
Long userId = claims.get("userId", Number.class).longValue();
String role = claims.get("role", String.class);
Date exp = claims.getExpiration();
전체 JWT 인증 흐름 (Spring Security 기준)
[클라이언트] ---> [Spring Security] ---> [JwtAuthenticationFilter] ---> [JwtTokenProvider]
↑ ↓
[Authorization: Bearer 토큰] 토큰 검증 및 유저 정보 추출
↓
[SecurityContextHolder에 인증 등록]
- 사용자가 로그인하여 JWT 토큰을 발급받음
- 이후 요청 시 Authorization: Bearer <토큰> 을 포함해서 보냄
- JwtAuthenticationFilter가 토큰을 파싱
- JwtTokenProvider가 유효성 검사 및 사용자 정보 파싱
- SecurityContextHolder에 인증 정보 등록
- 컨트롤러에서는 인증된 사용자로 인식하고 처리
① [클라이언트] 로그인 요청
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "1234"
}
→ 사용자가 로그인 정보를 서버에 전달
② [서버] AuthService에서 인증 확인 후 JWT 생성
String token = jwtTokenProvider.createToken(user.getId(), user.getEmail());
- 로그인 성공 시, JwtTokenProvider가 JWT 토큰을 생성
- 토큰 안에는 userId, email, 만료시간 같은 정보가 담겨 있음
- 그리고 secret key로 서명되어 위조 방지
③ [서버 → 클라이언트] 토큰 전달
{
"token": "eyJhbGciOiJIUzI1NiIsInR5..."
}
클라이언트는 토큰을 받아 로컬에 저장
(예: LocalStorage, SecureStorage 등)
④ [클라이언트] 이후 모든 요청에 토큰을 헤더에 포함
GET /api/words
Authorization: Bearer <JWT 토큰>
"나 이 토큰 가지고 있는 사람이야! 인증된 사용자야!" 라고 말하는 것
⑤ [서버] JwtAuthenticationFilter가 토큰을 검사
String token = resolveToken(request); // 헤더에서 꺼냄
jwtTokenProvider.validateToken(token); // 유효한지 검사
- 서명 검증
- 만료 여부 확인
- 파싱해서 userId, email 꺼냄
⑥ [서버] SecurityContext에 인증 정보 저장
UserPrincipal principal = jwtTokenProvider.getUserPrincipal(token);
Authentication auth = new UsernamePasswordAuthenticationToken(principal, "", authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
→ 이제 @AuthenticationPrincipal로 현재 유저 정보를 쉽게 꺼낼 수 있음!
@GetMapping
public ResponseEntity<List<Word>> getWords(@AuthenticationPrincipal UserPrincipal user) {
return wordService.getAllWords(user.getId());
}
보안 핵심 요약
| JWT | 사용자 정보 담긴 서명된 문자열 |
| JwtTokenProvider | 토큰 생성, 파싱, 검증 담당 |
| JwtAuthenticationFilter | 매 요청마다 토큰 꺼내서 유효한지 확인하고, 인증 처리 |
| SecurityConfig | 필터 등록, 인증 없이 접근 가능한 경로 설정 등 |
| UserPrincipal | 인증된 사용자 정보를 담는 객체 (UserDetails) |
| @AuthenticationPrincipal | 현재 로그인된 사용자 정보 자동 주입 |
예시 흐름 그림 요약
[ 로그인 요청 ]
⬇️
AuthController → AuthService → JwtTokenProvider → JWT 발급
⬇️
[ 클라이언트 저장 & Authorization 헤더에 포함 ]
⬇️
[ 요청 → JwtAuthenticationFilter → 검증 ]
⬇️
SecurityContext에 인증 정보 등록
⬇️
@AuthenticationPrincipal 으로 현재 유저 확인
이해를 돕는 비유
- JWT = 디지털 사인된 신분증
- Filter = 보안 게이트에서 ID 카드 검사
- SecurityContext = 현재 요청에 해당하는 유저 정보 저장소
- UserPrincipal = 로그인한 사람의 ID 카드 객체
1️⃣ JWT 토큰 안에는 뭐가 들어가나요?
JWT는 3가지 부분으로 구성된 .으로 나뉜 문자열입니다:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VySWQiOjEsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
구조
| Header | 서명 방식 (HS256) |
| Payload | 실제 사용자 정보, 예: userId, email |
| Signature | 위조 방지를 위한 서버 비밀키 서명 |
예시 Payload
{
"sub": "user@example.com",
"userId": 1,
"exp": 1714010000,
"iat": 1714006400
}
| sub | Subject: 주로 email |
| userId | 사용자 고유 ID |
| iat | 토큰 발급 시점 |
| exp | 만료 시각 (초 단위) |
2️⃣ 토큰을 Refresh 하려면 어떻게 해야 하나요?
Refresh Token 전략
JWT는 만료되면 재발급할 수 없기 때문에,
Refresh Token을 따로 만들어야 합니다.
방법
- Access Token: 1시간짜리 → 요청할 때마다 사용
- Refresh Token: 7일짜리 → DB나 Redis에 저장하고 만료 체크
- Access Token 만료 시, Refresh Token으로 새로운 Access Token 발급
흐름
[Access Token 만료됨]
→ Refresh Token 요청
→ 서버가 확인 후 새 Access Token + Refresh Token 발급
실전 팁
- Refresh Token은 DB에 저장
- Access Token은 JWT 하나로 Stateless 유지
원하시면 Refresh Token 적용한 구조도 추가로 알려드릴게요!
3️⃣ 로그아웃 처리는 어떻게 하나요?
JWT는 서버에 상태를 저장하지 않기 때문에,
기본적으로 로그아웃을 해도 서버는 알 수 없다.
해결 방법
- 클라이언트에서 JWT 제거 (로컬 스토리지에서 삭제)
- 블랙리스트 사용: 서버에서 만료되지 않은 토큰을 Redis 등에 저장하고 무효 처리
- Refresh Token 무효화 (Refresh 토큰을 DB에서 삭제)
실전에서는
- 로그아웃 = Refresh Token 무효화로 간주합니다
- Access Token은 1시간 이내 자동 만료
4️⃣ @AuthenticationPrincipal은 어떻게 값을 가져오나요?
내부 동작
- JwtAuthenticationFilter에서 토큰에서 유저 정보 꺼냄
- SecurityContextHolder.getContext().setAuthentication(...) 로 저장
- 이후 Controller에서는 @AuthenticationPrincipal 로 자동 주입
@GetMapping("/words")
public ResponseEntity<?> getWords(@AuthenticationPrincipal UserPrincipal user) {
Long userId = user.getId();
}
이게 가능한 이유
- UserPrincipal이 UserDetails를 implements 했고,
- Spring Security가 해당 객체를 현재 인증 정보로 인식하기 때문
헤더에 JWT를 실어 보내는 방법
규칙
Authorization: Bearer <JWT 토큰>
즉, Authorization이라는 헤더 키에
Bearer라는 접두사 + 공백 + 토큰을 붙이는 게 표준 방식입니다.
클라이언트 코드에서 요청 보내기
[1] React (Axios 사용)
axios.get("/api/words", {
headers: {
Authorization: `Bearer ${token}`
}
});
[2] Flutter (Dio 사용)
final dio = Dio();
dio.options.headers["Authorization"] = "Bearer $token";
final res = await dio.get("/api/words");
[3] 확장 프로그램 (fetch 사용)
fetch("https://api.example.com/words", {
headers: {
Authorization: `Bearer ${token}`
}
});
서버에서 받기
JwtAuthenticationFilter 내부
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
String token = bearer.substring(7); // "Bearer " 이후의 부분
// → 이 토큰을 validate & 파싱해서 인증 처리
}
JWT 인증의 강력한 장점
| 서버 세션 없이 인증 가능 | 상태를 서버가 기억할 필요 없음 (Stateless) |
| 분산 서버에 잘 어울림 | 여러 서버가 인증 상태를 공유할 필요 없음 |
| 어디서나 사용할 수 있음 | 웹, 앱, 확장 프로그램, 터미널 등 모든 클라이언트 가능 |
주의 점
- JWT는 https 환경에서만 사용하세요 (가로채기 방지)
- 토큰은 쿠키보다는 헤더 전송이 일반적 (XSS, CSRF에 덜 민감)
- 토큰 탈취되면 재발급 & 블랙리스트 전략 필요
클라이언트는 JWT를 Authorization: Bearer <token> 형식으로 헤더에 실어 서버로 보냅니다.
서버는 필터를 통해 헤더에서 꺼내서 인증을 수행합니다.
1. Axios (React/Next.js 등)
JWT 자동 추가 인터셉터 설정
// api/axiosInstance.ts
import axios from 'axios';
const axiosInstance = axios.create({
baseURL: 'https://api.example.com', // API 서버 주소
});
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token'); // 또는 secureStorage
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default axiosInstance;
사용 예시
import axiosInstance from './api/axiosInstance';
const res = await axiosInstance.get('/api/words');
2. Dio (Flutter)
JWT 자동 추가 Interceptor
final dio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await storage.read(key: "jwt_token"); // flutter_secure_storage
if (token != null) {
options.headers["Authorization"] = "Bearer $token";
}
return handler.next(options);
},
));
사용 예시
final res = await dio.get("/api/words");
3. Fetch (Vanilla JS or 확장 프로그램)
Fetch에는 인터셉터가 없어서 함수로 래핑해서 써요.
async function authFetch(url: string, options: RequestInit = {}) {
const token = localStorage.getItem("token");
const headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
return fetch(url, {
...options,
headers,
});
}
사용 예시
const res = await authFetch("https://api.example.com/api/words");
const data = await res.json();
토큰 저장 위치 TIP
| localStorage | 간단함, 브라우저에 보임 | XSS 취약 |
| sessionStorage | 탭 단위 저장 | XSS 여전히 있음 |
| httpOnly cookie | XSS 방지 | CSRF 위험 (Spring Security에서 설정 필요) |
| flutter_secure_storage | Flutter 안전한 저장소 | 플랫폼마다 구현 다름 |
1. 토큰 재발급 자동 처리 로직 (Refresh Token 흐름)
기본 구조
- Access Token은 짧게 (예: 1시간)
- Refresh Token은 길게 (예: 7일) + DB 또는 Redis에 저장
- Access Token 만료 → Refresh Token으로 재발급 요청
Axios Interceptor 로직 (React)
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
});
api.interceptors.request.use((config) => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
const res = await axios.post('/api/auth/refresh', { refreshToken });
const newAccessToken = res.data.accessToken;
localStorage.setItem('access_token', newAccessToken);
api.defaults.headers.Authorization = `Bearer ${newAccessToken}`;
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (e) {
window.location.href = '/login'; // refresh도 실패한 경우
return Promise.reject(e);
}
}
return Promise.reject(error);
}
);
export default api;
2. 401 Unauthorized 처리 → 자동 로그아웃 / 리다이렉트
의도
- 서버에서 "이 토큰은 만료됨!" → 401 Unauthorized 반환
- → 클라이언트는 로그인 페이지로 리다이렉트
if (error.response?.status === 401) {
localStorage.clear();
window.location.href = '/login';
}
이건 refresh까지 실패했을 때에만 실행되도록 처리하면 됩니다.
3. 헤더 방식 vs 쿠키 방식 비교 정리
헤더 방식 (Bearer Token) 쿠키 방식 (HttpOnly Cookie)
| 보안 | XSS 취약 (localStorage) | XSS 안전, CSRF 취약 |
| 자동 전송 | ❌ 수동으로 헤더에 넣음 | 브라우저가 자동으로 쿠키 전송 |
| 재발급 | 클라이언트가 직접 처리 | 쿠키 덕분에 자동화 쉬움 |
| 보안 처리 | HTTPS + 짧은 만료시간 필요 | HttpOnly + Secure + SameSite 설정 |
| 사용 용도 | SPA, 앱, 확장 등 자유로운 클라이언트 | 브라우저 기반 전용 (보안 위주) |
| 상황 | 사용 권장 | ❌ 설정 복잡, 확장프로그램에 부적합 |
1. CSRF란?
CSRF (Cross-Site Request Forgery)
"사이트 간 요청 위조"
시나리오:
- 사용자가 이미 로그인된 사이트(예: 네이버)에 접속 중
- 공격자가 만든 악성 사이트에 방문
- 그 사이트가 자동으로 POST http://naver.com/account/delete 같은 요청을 사용자 몰래 실행
- 쿠키/세션 인증이 자동으로 동작하면, 진짜 사용자로 인식되고 요청 성공 ❌
2. 왜 CSRF 보호가 필요할까?
| 인증 방식이 쿠키/세션 기반일 때 | 브라우저가 자동으로 쿠키를 보내기 때문에 외부 사이트도 공격 가능 |
| API 서버가 상태를 갖는 구조일 때 | 외부 요청이 내 서버 상태를 바꾸게 됨 |
→ 그래서 Spring Security는 기본적으로 CSRF 보호를 "활성화"시켜둠
3. 그런데 JWT 기반 API는 왜 .disable()을 써도 되는 걸까?
이유: JWT는 쿠키가 아니라, Authorization 헤더에 직접 넣기 때문
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
- CSRF는 브라우저가 자동으로 보내는 쿠키 때문에 위험한데,
- JWT는 개발자가 직접 헤더에 넣어야 하기 때문에,
외부 사이트가 몰래 요청을 보내더라도 인증이 되지 않음.
그럼에도 불구하고 CSRF 보호 전략이 필요할 수 있는 경우?
| JWT를 쿠키에 저장해서 사용하는 경우 | ✅ CSRF 토큰 필요! |
| 클라이언트가 브라우저이고 상태를 유지하는 요청 많을 때 | ✅ CSRF 토큰 or SameSite 쿠키 설정 |
| 폼 기반 로그인 등 섞여 있는 경우 | ✅ Spring의 CSRF 토큰을 프론트에 전달 & 함께 요청 |
CSRF 보호 전략
방법 1. Spring Security 기본 전략 사용
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
- Spring이 자동으로 CSRF 토큰을 쿠키에 심어주고
- 프론트는 요청 시 헤더에 같이 보내게 하면 OK
방법 2. 클라이언트에서 CSRF 토큰 받아서 직접 넣기
axios.defaults.headers['X-CSRF-TOKEN'] = getCsrfTokenFromCookie();
시나리오 1: JWT를 쿠키에 저장하는 경우
이때는 CSRF 공격에 노출될 수 있음 → 보호 필요!
흐름도
[로그인 요청] → [JWT 발급] → [Set-Cookie: jwt=...]
↓
브라우저에 저장됨 (자동)
🔁 [이후 요청들] → 브라우저가 자동으로 jwt 쿠키 전송
→ 서버는 쿠키에서 JWT 추출해 인증
😈 [악성사이트에서 요청] → 브라우저는 jwt 쿠키 자동 포함
→ ❌ CSRF 공격 성공 가능!
해결 전략 1: CSRF 토큰 추가 (Spring Security 제공)
[요청 전] → 서버가 쿠키로 csrf 토큰도 전달 (HttpOnly 아님)
→ 클라이언트가 해당 토큰을 header에 실어서 전송
서버는: 쿠키로 받은 csrf 값 === 헤더로 받은 csrf 값 ?
같으면 요청 허용
👉 Spring에선 CookieCsrfTokenRepository로 자동 제공 가능
해결 전략 2: SameSite 쿠키 옵션 설정
- Set-Cookie: jwt=...; SameSite=Strict
- 외부 사이트에서 보낸 요청에는 쿠키 자동 포함이 안 됨
[정상 요청] → Same-origin 요청이라 쿠키 포함 OK
[CSRF 요청] → 다른 사이트에서 보내는 요청은 쿠키 미포함 → 인증 실패!
⚠️ 단점: 일부 브라우저 호환성 / 필요 시 cross-site 요청 제한될 수 있음
시나리오 2: JWT를 로컬스토리지에 저장하는 경우
이 경우는 CSRF 보호가 사실상 필요 없음
[로그인 시 JWT 응답] → 클라이언트가 localStorage.setItem("jwt", token)
🔁 [이후 요청들] → 클라이언트가 직접 Authorization 헤더에 jwt를 넣음
→ 외부 사이트는 헤더 조작 불가능
이 방식은 CSRF에 안전하지만
XSS 공격에 노출되기 쉬움 → 클라이언트 보안 강화 필요
추천 전략 (상황별)
| 민감한 보안 필요 (은행 등) | 쿠키 + HttpOnly + CSRF 토큰 + SameSite |
| SPA 기반 프론트 중심 앱 | 로컬스토리지 + HTTPS + 철저한 XSS 방어 |
| 하이브리드 앱 (모바일 등) | Authorization 헤더 + 로컬 저장소 (토큰 관리 편함) |
크롬 확장 + SPA 웹 + Flutter 앱 → 모두 한 백엔드에서 JWT 인증을 어떻게 안전하게 구성할 것인가?
사용 환경 정리
| 🧩 SPA 웹 (React 등) | 브라우저 환경, XSS/CSRF 둘 다 주의 필요 |
| 🧩 크롬 확장 프로그램 | 브라우저 기반, but iframe X, 스크립트 격리 |
| 🧩 Flutter 앱 | 앱 환경, XSS/CSRF 거의 없음, 토큰 직접 관리 가능 |
이 세 환경은 서로 보안 위협 모델이 다르기 때문에, JWT 저장/전달 방식도 유연하게 설계
전략 목표
| 하나의 백엔드로 통일된 JWT 발급 처리 | |
| 각 클라이언트가 적절한 방식으로 JWT 저장 및 사용 | |
| 보안은 "환경별 위협"에 맞춰 분리 대응 |
추천 아키텍처 설계
1. JWT는 항상 Authorization 헤더로 통신 (공통 원칙)
Authorization: Bearer <your-jwt-token>
이걸 모든 플랫폼이 동일하게 사용 → 백엔드 인증 로직 일관성 확보
2. 각 플랫폼별 JWT 저장 전략
| SPA 웹 | XSS 방지 우선 → HttpOnly 쿠키 권장or 로컬스토리지 + CSP/XSS 방어 | 로컬스토리지는 편하지만 XSS에 취약쿠키 + CSRF 전략이 필요 |
| 크롬 확장 | localStorage 또는 chrome.storage.local | 확장 내부는 비교적 안전 (DOM 분리됨) |
| Flutter 앱 | secure storage (예: flutter_secure_storage) | XSS/CSRF 거의 없음 → 토큰 직접 보관 OK |
3. 백엔드: 플랫폼 구분은 필요 없음
- 백엔드는 항상 Authorization 헤더만 검사
- 프론트가 쿠키든 로컬스토리지든 알아서 헤더에 넣어주면 됨
로그인 응답 시 헤더 vs 쿠키
| JWT를 응답 바디에 반환 | 일반적 방식. 클라이언트가 직접 저장 |
| JWT를 쿠키(HttpOnly)에 심어줌 | 브라우저 기반에서는 CSRF 방어와 함께 사용해야 안전 |
| SPA 웹 | ✅ HttpOnly 쿠키 (or 로컬스토리지) | 브라우저가 자동 전송 (or 헤더) | CSRF 방어 전략 필수 |
| 크롬 확장 | ✅ chrome.storage.local | Authorization 헤더 직접 추가 | 크로스 요청 시 CORS 대응 |
| Flutter 앱 | ✅ secure storage | Authorization 헤더 직접 추가 | 없음 (상대적으로 안전) |
실전 설계
// 모든 요청에 공통 인터셉터 추가 (axios 예시)
axios.interceptors.request.use((config) => {
const token = loadTokenFromStorage(); // 각 플랫폼별 저장 위치에서 불러오기
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
“JWT는 항상 헤더로 보내고, 저장 전략은 환경에 맞게 분리”
- 백엔드는 일관된 처리
- 프론트는 각기 다른 위협 모델에 맞춰 저장 전략 분리
'Java > Spring Boot' 카테고리의 다른 글
| Redis, kafka (0) | 2025.03.27 |
|---|---|
| HTTP 상태 코드 종류 (0) | 2025.03.27 |
| [restartedMain] i.n.r.d.DnsServerAddressStreamProviders : Unable to load io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider, fallback to system defaults. This may result in incorrect DNS resolutions on MacOS. (3) | 2025.01.30 |
| [Spring Boot] email 전송하기 (2) | 2025.01.29 |
| [SpringBoot] Thymeleaf (1) | 2024.12.25 |