본문 바로가기
Java/Spring Boot

JWT & CRSF

by curious week 2025. 3. 25.

 

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의 종류

  1. Registered claims (표준 클레임)
    • sub: Subject (주제)
    • iss: Issuer (발급자)
    • aud: Audience (대상)
    • exp: Expiration (만료 시간)
    • nbf: Not Before (활성 시작 시간)
    • iat: Issued At (발급 시각)
    • jti: JWT ID (토큰 고유 ID)
  2. Public claims (공개 클레임)
    • 예: email, role 등. 사용자가 정한 이름이지만 충돌 방지를 위해 URI 명시 권장
  3. 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에 인증 등록]
  1. 사용자가 로그인하여 JWT 토큰을 발급받음
  2. 이후 요청 시 Authorization: Bearer <토큰> 을 포함해서 보냄
  3. JwtAuthenticationFilter가 토큰을 파싱
  4. JwtTokenProvider가 유효성 검사 및 사용자 정보 파싱
  5. SecurityContextHolder에 인증 정보 등록
  6. 컨트롤러에서는 인증된 사용자로 인식하고 처리

① [클라이언트] 로그인 요청

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는 서버에 상태를 저장하지 않기 때문에,
기본적으로 로그아웃을 해도 서버는 알 수 없다.

해결 방법

  1. 클라이언트에서 JWT 제거 (로컬 스토리지에서 삭제)
  2. 블랙리스트 사용: 서버에서 만료되지 않은 토큰을 Redis 등에 저장하고 무효 처리
  3. Refresh Token 무효화 (Refresh 토큰을 DB에서 삭제)

실전에서는

  • 로그아웃 = Refresh Token 무효화로 간주합니다
  • Access Token은 1시간 이내 자동 만료

4️⃣ @AuthenticationPrincipal은 어떻게 값을 가져오나요?

내부 동작

  1. JwtAuthenticationFilter에서 토큰에서 유저 정보 꺼냄
  2. SecurityContextHolder.getContext().setAuthentication(...) 로 저장
  3. 이후 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 흐름)

기본 구조

  1. Access Token은 짧게 (예: 1시간)
  2. Refresh Token은 길게 (예: 7일) + DB 또는 Redis에 저장
  3. 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)
"사이트 간 요청 위조"

시나리오:

  1. 사용자가 이미 로그인된 사이트(예: 네이버)에 접속 중
  2. 공격자가 만든 악성 사이트에 방문
  3. 그 사이트가 자동으로 POST http://naver.com/account/delete 같은 요청을 사용자 몰래 실행
  4. 쿠키/세션 인증이 자동으로 동작하면, 진짜 사용자로 인식되고 요청 성공 ❌

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는 항상 헤더로 보내고, 저장 전략은 환경에 맞게 분리”

  • 백엔드는 일관된 처리
  • 프론트는 각기 다른 위협 모델에 맞춰 저장 전략 분리