Skip to content

필사 모드: Spring Boot Authentication Practical Guide — Session, JWT, SecurityContext, Cookie-Based Auth Complete Mastery

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

> **SSO Cookie/JWT Authentication Series** · [Index](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-series-index) · Current: Spring Boot Edition · [Django Edition](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-django)

Overview — Spring Boot Authentication Architecture

Spring Security builds security logic on top of the **Servlet Filter Chain**. Before an HTTP request reaches the controller, it passes through multiple security filters where Authentication and Authorization take place.

HTTP Request

|

DelegatingFilterProxy

|

FilterChainProxy

|

SecurityFilterChain

|-- CorsFilter

|-- CsrfFilter

|-- UsernamePasswordAuthenticationFilter (Session-based)

|-- JwtAuthenticationFilter (Custom JWT filter)

|-- ExceptionTranslationFilter

+-- AuthorizationFilter

|

DispatcherServlet -> Controller

The core flow is as follows:

1. **SecurityFilterChain** — Determines which filters to apply to which requests

2. **AuthenticationManager** — Manager that receives authentication processing delegation

3. **AuthenticationProvider** — Performs actual authentication logic (DB lookup, password verification, etc.)

4. **SecurityContext** — Stores authenticated user information in thread-local

// Basic structure

SecurityContextHolder

+-- SecurityContext

+-- Authentication

|-- Principal (User info)

|-- Credentials (Password/Token)

+-- Authorities (Permission list)

Session-Based Authentication

The most traditional approach. The server creates an `HttpSession` and issues a `JSESSIONID` cookie to the client.

Login Handler Implementation

@RestController

@RequestMapping("/api/auth")

public class SessionAuthController {

private final AuthenticationManager authenticationManager;

public SessionAuthController(AuthenticationManager authenticationManager) {

this.authenticationManager = authenticationManager;

}

@PostMapping("/login")

public ResponseEntity<?> login(@RequestBody LoginRequest request,

HttpServletRequest httpRequest) {

// 1. Authentication attempt

Authentication authentication = authenticationManager.authenticate(

new UsernamePasswordAuthenticationToken(

request.getUsername(),

request.getPassword()

)

);

// 2. Store in SecurityContext

SecurityContextHolder.getContext().setAuthentication(authentication);

// 3. Create session (JSESSIONID cookie automatically issued)

HttpSession session = httpRequest.getSession(true);

session.setAttribute(

HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,

SecurityContextHolder.getContext()

);

UserDetails userDetails = (UserDetails) authentication.getPrincipal();

return ResponseEntity.ok(Map.of(

"username", userDetails.getUsername(),

"roles", userDetails.getAuthorities()

));

}

@PostMapping("/logout")

public ResponseEntity<?> logout(HttpServletRequest request) {

HttpSession session = request.getSession(false);

if (session != null) {

session.invalidate(); // Invalidate session

}

SecurityContextHolder.clearContext();

return ResponseEntity.ok(Map.of("message", "Logout successful"));

}

}

SecurityConfig (Session-Based)

@Configuration

@EnableWebSecurity

public class SessionSecurityConfig {

@Bean

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

http

.csrf(csrf -> csrf.csrfTokenRepository(

CookieCsrfTokenRepository.withHttpOnlyFalse()))

.authorizeHttpRequests(auth -> auth

.requestMatchers("/api/auth/login", "/api/public/**").permitAll()

.requestMatchers("/api/admin/**").hasRole("ADMIN")

.anyRequest().authenticated()

)

.sessionManagement(session -> session

.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)

.maximumSessions(1) // Concurrent session limit

.maxSessionsPreventsLogin(false) // Expire existing session

);

return http.build();

}

@Bean

public AuthenticationManager authenticationManager(

AuthenticationConfiguration config) throws Exception {

return config.getAuthenticationManager();

}

}

Session-based authentication is simple to implement, but **session sharing issues arise when scaling out servers**. This can be resolved with Redis-based Spring Session, but it increases complexity.

JWT-Based Authentication

JWT Token Generation

Uses the `io.jsonwebtoken` (jjwt) library. Add the dependency to `build.gradle`.

// build.gradle

dependencies {

implementation 'io.jsonwebtoken:jjwt-api:0.12.5'

runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'

runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

}

@Component

public class JwtTokenProvider {

@Value("${jwt.secret}")

private String secretKey;

@Value("${jwt.access-token-expiration}")

private long accessTokenExpiration; // e.g. 900000 (15 min)

@Value("${jwt.refresh-token-expiration}")

private long refreshTokenExpiration; // e.g. 604800000 (7 days)

private SecretKey key;

@PostConstruct

public void init() {

byte[] keyBytes = Decoders.BASE64.decode(secretKey);

this.key = Keys.hmacShaKeyFor(keyBytes);

}

/** Generate Access Token */

public String generateAccessToken(UserDetails userDetails, String tenantId) {

Map<String, Object> claims = new HashMap<>();

claims.put("roles", userDetails.getAuthorities().stream()

.map(GrantedAuthority::getAuthority)

.collect(Collectors.toList()));

claims.put("tenant_id", tenantId);

claims.put("token_type", "access");

return Jwts.builder()

.claims(claims)

.subject(userDetails.getUsername())

.issuedAt(new Date())

.expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))

.signWith(key)

.compact();

}

/** Generate Refresh Token */

public String generateRefreshToken(UserDetails userDetails) {

return Jwts.builder()

.subject(userDetails.getUsername())

.claim("token_type", "refresh")

.issuedAt(new Date())

.expiration(new Date(System.currentTimeMillis() + refreshTokenExpiration))

.signWith(key)

.compact();

}

/** Extract Claims from token */

public Claims extractAllClaims(String token) {

return Jwts.parser()

.verifyWith(key)

.build()

.parseSignedClaims(token)

.getPayload();

}

/** Extract username from token */

public String extractUsername(String token) {

return extractAllClaims(token).getSubject();

}

/** Validate token */

public boolean isTokenValid(String token, UserDetails userDetails) {

try {

Claims claims = extractAllClaims(token);

String username = claims.getSubject();

Date expiration = claims.getExpiration();

return username.equals(userDetails.getUsername())

&& expiration.after(new Date());

} catch (JwtException | IllegalArgumentException e) {

return false;

}

}

}

JWT Filter Implementation

Extends `OncePerRequestFilter` to verify JWT once per request. Supports both **Authorization header** and **HttpOnly cookie** methods.

@Component

public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenProvider jwtTokenProvider;

private final UserDetailsService userDetailsService;

private final TokenBlacklistService tokenBlacklistService;

public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,

UserDetailsService userDetailsService,

TokenBlacklistService tokenBlacklistService) {

this.jwtTokenProvider = jwtTokenProvider;

this.userDetailsService = userDetailsService;

this.tokenBlacklistService = tokenBlacklistService;

}

@Override

protected void doFilterInternal(HttpServletRequest request,

HttpServletResponse response,

FilterChain filterChain)

throws ServletException, IOException {

String token = resolveToken(request);

if (token != null && !tokenBlacklistService.isBlacklisted(token)) {

try {

String username = jwtTokenProvider.extractUsername(token);

if (username != null &&

SecurityContextHolder.getContext().getAuthentication() == null) {

UserDetails userDetails =

userDetailsService.loadUserByUsername(username);

if (jwtTokenProvider.isTokenValid(token, userDetails)) {

UsernamePasswordAuthenticationToken authToken =

new UsernamePasswordAuthenticationToken(

userDetails,

null,

userDetails.getAuthorities()

);

authToken.setDetails(

new WebAuthenticationDetailsSource()

.buildDetails(request)

);

SecurityContextHolder.getContext()

.setAuthentication(authToken);

}

}

} catch (JwtException e) {

// Invalid token — proceed to next filter without authentication

logger.warn("JWT validation failed: " + e.getMessage());

}

}

filterChain.doFilter(request, response);

}

/**

* Token extraction: Authorization header first, then cookie

*/

private String resolveToken(HttpServletRequest request) {

// 1) Authorization: Bearer <token>

String bearerToken = request.getHeader("Authorization");

if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {

return bearerToken.substring(7);

}

// 2) HttpOnly Cookie: access_token=<token>

if (request.getCookies() != null) {

return Arrays.stream(request.getCookies())

.filter(c -> "access_token".equals(c.getName()))

.findFirst()

.map(Cookie::getValue)

.orElse(null);

}

return null;

}

}

SecurityFilterChain Configuration (JWT)

@Configuration

@EnableWebSecurity

@EnableMethodSecurity // Enable @PreAuthorize, @Secured

public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthFilter;

private final AuthenticationProvider authenticationProvider;

public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,

AuthenticationProvider authenticationProvider) {

this.jwtAuthFilter = jwtAuthFilter;

this.authenticationProvider = authenticationProvider;

}

@Bean

public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

http

// CSRF: Can be disabled when using JWT + SameSite cookies

.csrf(AbstractHttpConfigurer::disable)

// CORS configuration

.cors(cors -> cors.configurationSource(corsConfigurationSource()))

// Do not use sessions (STATELESS)

.sessionManagement(session ->

session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

// URL-based authorization rules

.authorizeHttpRequests(auth -> auth

.requestMatchers(

"/api/auth/login",

"/api/auth/register",

"/api/auth/refresh",

"/api/public/**",

"/actuator/health"

).permitAll()

.requestMatchers("/api/admin/**").hasRole("ADMIN")

.requestMatchers("/api/tenant/**").hasAnyRole("ADMIN", "TENANT_ADMIN")

.anyRequest().authenticated()

)

// Register AuthenticationProvider

.authenticationProvider(authenticationProvider)

// Place JWT filter before UsernamePasswordAuthenticationFilter

.addFilterBefore(jwtAuthFilter,

UsernamePasswordAuthenticationFilter.class)

// Authentication failure handling

.exceptionHandling(ex -> ex

.authenticationEntryPoint((request, response, authException) -> {

response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

response.setContentType("application/json;charset=UTF-8");

response.getWriter().write(

"{\"error\":\"Authentication required\",\"status\":401}");

})

.accessDeniedHandler((request, response, accessDeniedException) -> {

response.setStatus(HttpServletResponse.SC_FORBIDDEN);

response.setContentType("application/json;charset=UTF-8");

response.getWriter().write(

"{\"error\":\"Access denied\",\"status\":403}");

})

);

return http.build();

}

@Bean

public CorsConfigurationSource corsConfigurationSource() {

CorsConfiguration config = new CorsConfiguration();

config.setAllowedOrigins(List.of(

"https://app.example.com",

"https://admin.example.com"

));

config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));

config.setAllowedHeaders(List.of("*"));

config.setAllowCredentials(true); // Allow cookie transmission

config.setMaxAge(3600L);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

source.registerCorsConfiguration("/api/**", config);

return source;

}

}

Cookie Configuration in Practice

When JWTs are sent via cookies, the frontend does not need to manage tokens. The combination of **HttpOnly + Secure + SameSite** is the key.

Cookie Creation Utility

@Component

public class CookieUtil {

@Value("${app.cookie.domain}")

private String cookieDomain; // e.g. ".example.com"

@Value("${app.cookie.secure}")

private boolean secure; // Production: true, Local: false

/**

* Create Access Token cookie

*/

public ResponseCookie createAccessTokenCookie(String token) {

return ResponseCookie.from("access_token", token)

.httpOnly(true) // Block JS access (XSS defense)

.secure(secure) // HTTPS only

.sameSite("Lax") // Basic CSRF defense

.domain(cookieDomain) // Subdomain sharing

.path("/") // All paths

.maxAge(Duration.ofMinutes(15))

.build();

}

/**

* Create Refresh Token cookie

*/

public ResponseCookie createRefreshTokenCookie(String token) {

return ResponseCookie.from("refresh_token", token)

.httpOnly(true)

.secure(secure)

.sameSite("Strict") // Stricter for Refresh

.domain(cookieDomain)

.path("/api/auth/refresh") // Only sent to refresh endpoint

.maxAge(Duration.ofDays(7))

.build();

}

/**

* Delete cookie (maxAge=0)

*/

public ResponseCookie deleteAccessTokenCookie() {

return ResponseCookie.from("access_token", "")

.httpOnly(true)

.secure(secure)

.sameSite("Lax")

.domain(cookieDomain)

.path("/")

.maxAge(0) // Delete immediately

.build();

}

public ResponseCookie deleteRefreshTokenCookie() {

return ResponseCookie.from("refresh_token", "")

.httpOnly(true)

.secure(secure)

.sameSite("Strict")

.domain(cookieDomain)

.path("/api/auth/refresh")

.maxAge(0)

.build();

}

}

Setting Cookies in Login Response

@RestController

@RequestMapping("/api/auth")

public class AuthController {

private final AuthenticationManager authManager;

private final JwtTokenProvider jwtTokenProvider;

private final CookieUtil cookieUtil;

// Constructor omitted

@PostMapping("/login")

public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {

Authentication auth = authManager.authenticate(

new UsernamePasswordAuthenticationToken(

request.getUsername(), request.getPassword()));

UserDetails userDetails = (UserDetails) auth.getPrincipal();

String tenantId = ((CustomUserDetails) userDetails).getTenantId();

String accessToken = jwtTokenProvider.generateAccessToken(userDetails, tenantId);

String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

ResponseCookie accessCookie = cookieUtil.createAccessTokenCookie(accessToken);

ResponseCookie refreshCookie = cookieUtil.createRefreshTokenCookie(refreshToken);

return ResponseEntity.ok()

.header(HttpHeaders.SET_COOKIE, accessCookie.toString())

.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())

.body(Map.of(

"message", "Login successful",

"username", userDetails.getUsername(),

"roles", userDetails.getAuthorities()

));

}

}

Claim Parsing and User Information Access

Custom UserDetails

@Getter

public class CustomUserDetails implements UserDetails {

private final Long id;

private final String username;

private final String password;

private final String tenantId;

private final Collection<? extends GrantedAuthority> authorities;

public CustomUserDetails(User user) {

this.id = user.getId();

this.username = user.getUsername();

this.password = user.getPassword();

this.tenantId = user.getTenantId();

this.authorities = user.getRoles().stream()

.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))

.collect(Collectors.toList());

}

}

Accessing User Information in Controllers

@RestController

@RequestMapping("/api/users")

public class UserController {

// Method 1: Direct SecurityContextHolder usage

@GetMapping("/me")

public ResponseEntity<?> getCurrentUser() {

Authentication auth = SecurityContextHolder.getContext().getAuthentication();

CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();

return ResponseEntity.ok(Map.of(

"id", user.getId(),

"username", user.getUsername(),

"tenantId", user.getTenantId(),

"roles", user.getAuthorities()

));

}

// Method 2: @AuthenticationPrincipal annotation (recommended)

@GetMapping("/profile")

public ResponseEntity<?> getProfile(

@AuthenticationPrincipal CustomUserDetails user) {

return ResponseEntity.ok(Map.of(

"id", user.getId(),

"username", user.getUsername(),

"tenantId", user.getTenantId()

));

}

// Method 3: Extract directly from JWT Claims

@GetMapping("/claims")

public ResponseEntity<?> getClaimsInfo(HttpServletRequest request) {

String token = extractTokenFromCookie(request);

Claims claims = jwtTokenProvider.extractAllClaims(token);

return ResponseEntity.ok(Map.of(

"sub", claims.getSubject(),

"roles", claims.get("roles"),

"tenant_id", claims.get("tenant_id"),

"exp", claims.getExpiration(),

"iat", claims.getIssuedAt()

));

}

// Method 4: Method-level authorization with @PreAuthorize

@PreAuthorize("hasRole('ADMIN')")

@GetMapping("/admin/all")

public ResponseEntity<?> getAllUsers() {

// ADMIN only

return ResponseEntity.ok(userService.findAll());

}

@PreAuthorize("#userId == authentication.principal.id")

@PutMapping("/{userId}")

public ResponseEntity<?> updateUser(@PathVariable Long userId,

@RequestBody UpdateRequest req) {

// Can only modify own profile

return ResponseEntity.ok(userService.update(userId, req));

}

}

Browser Storage Accessibility Table

| Storage | JS Access | Auto Server Send | XSS Vulnerable | CSRF Vulnerable |

| ------------------------------ | ------------- | ---------------------- | --------------- | --------------------------- |

| **localStorage** | Yes | No | **Yes (risky)** | No |

| **sessionStorage** | Yes | No | **Yes (risky)** | No |

| **Regular Cookie** | Yes | Yes (same domain) | **Yes** | **Yes (risky)** |

| **HttpOnly Cookie** | **No (safe)** | Yes (same domain) | **No (safe)** | Yes (mitigated by SameSite) |

| **HttpOnly + SameSite=Strict** | **No** | Same site only | **No** | **No (safe)** |

| **HttpOnly + SameSite=Lax** | **No** | Cross-site GET allowed | **No** | Nearly safe |

**Recommended combination**: `HttpOnly + Secure + SameSite=Lax` (Access Token), `HttpOnly + Secure + SameSite=Strict` (Refresh Token)

CORS + Credential Transmission Setup

When frontend and backend domains differ, **CORS + credentials** configuration is required to transmit cookies.

Spring Boot CORS Configuration

@Bean

public CorsConfigurationSource corsConfigurationSource() {

CorsConfiguration config = new CorsConfiguration();

// Cannot use * in allowedOrigins (when credentials: true)

config.setAllowedOrigins(List.of(

"https://app.example.com",

"https://admin.example.com"

));

config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));

config.setAllowedHeaders(List.of(

"Authorization", "Content-Type", "X-Requested-With", "Accept"));

config.setExposedHeaders(List.of("Set-Cookie"));

config.setAllowCredentials(true); // Must be true

config.setMaxAge(3600L);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

source.registerCorsConfiguration("/**", config);

return source;

}

Frontend (fetch / axios)

// fetch

const res = await fetch('https://api.example.com/api/users/me', {

method: 'GET',

credentials: 'include', // Required for cookie transmission

})

// axios global configuration

axios.defaults.withCredentials = true

> **Note**: When using `allowCredentials(true)`, you cannot use the `"*"` wildcard in `allowedOrigins`. You must explicitly list domains.

Logout and Token Invalidation

Since JWT does not store state on the server, "invalidation" is difficult. A **Redis-based blacklist** is the most practical approach.

Redis Blacklist Service

@Service

public class TokenBlacklistService {

private final RedisTemplate<String, String> redisTemplate;

public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {

this.redisTemplate = redisTemplate;

}

/**

* Add token to blacklist (set TTL to remaining expiration time)

*/

public void blacklist(String token, long remainingMillis) {

redisTemplate.opsForValue().set(

"blacklist:" + token,

"revoked",

Duration.ofMillis(remainingMillis)

);

}

/**

* Check if token is blacklisted

*/

public boolean isBlacklisted(String token) {

return Boolean.TRUE.equals(

redisTemplate.hasKey("blacklist:" + token));

}

}

Logout Endpoint

@PostMapping("/logout")

public ResponseEntity<?> logout(HttpServletRequest request) {

String token = resolveToken(request);

if (token != null) {

try {

Claims claims = jwtTokenProvider.extractAllClaims(token);

long remainingMillis =

claims.getExpiration().getTime() - System.currentTimeMillis();

if (remainingMillis > 0) {

tokenBlacklistService.blacklist(token, remainingMillis);

}

} catch (JwtException ignored) {

// Already expired tokens don't need to be blacklisted

}

}

// Delete cookies

ResponseCookie deleteAccess = cookieUtil.deleteAccessTokenCookie();

ResponseCookie deleteRefresh = cookieUtil.deleteRefreshTokenCookie();

return ResponseEntity.ok()

.header(HttpHeaders.SET_COOKIE, deleteAccess.toString())

.header(HttpHeaders.SET_COOKIE, deleteRefresh.toString())

.body(Map.of("message", "Logout successful"));

}

Token Refresh Strategy

Set Access Token short (15 minutes) and Refresh Token long (7 days), and issue new Access Tokens using the Refresh Token. Applying **Refresh Token Rotation** ensures that even stolen Refresh Tokens can only be used once.

Refresh Endpoint

@PostMapping("/refresh")

public ResponseEntity<?> refresh(HttpServletRequest request) {

// 1. Extract Refresh Token from cookie

String refreshToken = Arrays.stream(

Optional.ofNullable(request.getCookies()).orElse(new Cookie[0]))

.filter(c -> "refresh_token".equals(c.getName()))

.findFirst()

.map(Cookie::getValue)

.orElseThrow(() -> new AuthException("Refresh Token not found"));

// 2. Validate Refresh Token

Claims claims;

try {

claims = jwtTokenProvider.extractAllClaims(refreshToken);

} catch (JwtException e) {

throw new AuthException("Invalid Refresh Token");

}

if (!"refresh".equals(claims.get("token_type"))) {

throw new AuthException("Wrong token type");

}

// 3. Check blacklist

if (tokenBlacklistService.isBlacklisted(refreshToken)) {

throw new AuthException("Already used Refresh Token (Rotation violation detected)");

}

// 4. Load user information

String username = claims.getSubject();

CustomUserDetails userDetails =

(CustomUserDetails) userDetailsService.loadUserByUsername(username);

// 5. Blacklist existing Refresh Token (Rotation)

long remaining = claims.getExpiration().getTime() - System.currentTimeMillis();

tokenBlacklistService.blacklist(refreshToken, remaining);

// 6. Issue new token pair

String newAccessToken = jwtTokenProvider.generateAccessToken(

userDetails, userDetails.getTenantId());

String newRefreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

// 7. Set new cookies

return ResponseEntity.ok()

.header(HttpHeaders.SET_COOKIE,

cookieUtil.createAccessTokenCookie(newAccessToken).toString())

.header(HttpHeaders.SET_COOKIE,

cookieUtil.createRefreshTokenCookie(newRefreshToken).toString())

.body(Map.of("message", "Token refresh successful"));

}

**Rotation violation detection**: If a Refresh Token already in the blacklist is used, it is considered an attack attempt, and defensive logic can be added to **invalidate all tokens** for that user.

Security Trade-offs

| Threat | localStorage JWT | HttpOnly Cookie JWT | Session |

| ----------------- | ----------------------- | --------------------------- | -------------------------- |

| **XSS** | Immediately stealable | JS access blocked (safe) | Session ID inaccessible |

| **CSRF** | Not applicable | Defend with SameSite + CORS | CSRF token required |

| **Token theft** | Easily stolen via XSS | Network-level only | Risk if session ID leaked |

| **Replay attack** | Mitigate with short TTL | Short TTL + blacklist | Server invalidates session |

| **Scalability** | Easy horizontal scaling | Easy horizontal scaling | Redis Session needed |

| **Server load** | None | Blacklist lookup | Session storage |

**Conclusion**: In most cases, **HttpOnly Cookie + SameSite=Lax + Secure + short expiration + Refresh Token Rotation** is the most practical choice.

Checklist

Verify the following items when implementing Spring Boot JWT authentication:

- [ ] Is the JWT Secret Key sufficiently long (256 bits or more)?

- [ ] Is the Secret Key loaded from environment variables/Vault (not hardcoded)?

- [ ] Is the Access Token expiration time short enough (15 minutes or less recommended)?

- [ ] Is Refresh Token Rotation applied?

- [ ] Are HttpOnly + Secure + SameSite cookie settings configured?

- [ ] Are CORS allowCredentials(true) and explicit Origins set?

- [ ] Is STATELESS session policy set in SecurityFilterChain?

- [ ] Are exceptions in the JWT filter handled safely (preventing 500 errors)?

- [ ] Are cookie deletion + token blacklist implemented on logout?

- [ ] Does the Token blacklist TTL match the token expiration time?

- [ ] Are @PreAuthorize or URL-based authorization rules configured?

- [ ] Is CSRF configuration appropriate for the API characteristics?

- [ ] Are ExceptionHandling (authenticationEntryPoint, accessDeniedHandler) configured?

- [ ] Does the Refresh endpoint only accept Refresh Tokens (token_type validation)?

- [ ] Are sensitive Claims (passwords, etc.) excluded from the JWT?

Common Bugs and Misconceptions

1. "It's always okay to disable CSRF"

If JWT is sent via the **Authorization header**, CSRF attacks are impossible (browser doesn't auto-attach it). However, if **JWT is stored in cookies**, disabling CSRF without SameSite settings creates a vulnerability.

2. "Sensitive info in JWT is safe because it's encrypted"

JWT is **Signed**, not **Encrypted**. Anyone can see the Payload by simply Base64 decoding it. Never include passwords, social security numbers, or other sensitive information.

3. "SecurityContextHolder is always thread-safe"

The default strategy is `ThreadLocal`, so it is safe within the same thread. However, in code executing in **different threads** such as `@Async`, `CompletableFuture`, or WebFlux, SecurityContext is not propagated. You need to use `SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)` or `DelegatingSecurityContextExecutor`.

4. "It's OncePerRequestFilter but why is it executing twice?"

The filter can execute again during servlet forwarding (`RequestDispatcher.forward()`) or error dispatch. Override the `shouldNotFilter()` method or check `shouldNotFilterErrorDispatch()`.

5. "I put \* in allowedOrigins but cookies aren't being sent"

`allowCredentials(true)` and `allowedOrigins("*")` cannot be used together. You must explicitly list domains or use `allowedOriginPatterns("*")` (not recommended for security).

6. "Is it okay to store Refresh Token in localStorage?"

The Refresh Token has a longer lifespan than the Access Token and is a sensitive token that can issue new Access Tokens. Since localStorage is vulnerable to XSS, it **must be stored in HttpOnly cookies**.

7. "I set JWT expiration to 1 year"

If the Access Token expiration is long, the damage from theft increases. Access Token is typically 15 minutes or less, and Refresh Token is 7-30 days.

References

1. [Spring Security Reference — Architecture](https://docs.spring.io/spring-security/reference/servlet/architecture.html) — Filter chain architecture

2. [Spring Security Reference — Authentication](https://docs.spring.io/spring-security/reference/servlet/authentication/index.html) — Authentication mechanisms

3. [Spring Security Reference — Authorization](https://docs.spring.io/spring-security/reference/servlet/authorization/index.html) — Authorization mechanisms

4. [jjwt GitHub](https://github.com/jwtk/jjwt) — Java JWT library

5. [RFC 7519 — JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) — JWT standard spec

6. [RFC 6749 — OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) — OAuth 2.0 framework

7. [OWASP — Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) — Session management security

8. [OWASP — JWT Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) — JWT security

9. [OWASP — Cross-Site Request Forgery Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) — CSRF defense

10. [MDN — Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) — Cookie header spec

11. [MDN — SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) — SameSite attribute

12. [Spring Boot + Redis Session](https://docs.spring.io/spring-session/reference/guides/boot-redis.html) — Redis-based session sharing

13. [Baeldung — Spring Security JWT](https://www.baeldung.com/spring-security-oauth-jwt) — JWT authentication tutorial

현재 단락 (1/636)

Spring Security builds security logic on top of the **Servlet Filter Chain**. Before an HTTP request...

작성 글자: 0원문 글자: 25,524작성 단락: 0/636