Security Engineering
with Spring Boot
Production security isn't about adding @EnableWebSecurity and hoping for the best. This section teaches you how Spring Security actually works, how authentication and authorization are implemented at the framework level, and how real attackers exploit misconfigured Spring applications — so you can stop them.
Spring Security Internals
Most developers treat Spring Security as a black box — add the dependency, configure some rules, and it works. But when it doesn't work (or when security is subtly broken), you need to understand what's actually happening inside the framework.
The Filter Chain Architecture
Spring Security is a chain of servlet filters. Every HTTP request passes through this chain before reaching your controllers. The chain is the security perimeter.
SecurityContext and SecurityContextHolder
Once a request is authenticated, Spring stores the authentication details in the SecurityContext, which is held in SecurityContextHolder. By default this uses ThreadLocal storage — meaning the authenticated user is available anywhere in the thread for the duration of the request.
// From any bean — no injection needed
SecurityContext ctx = SecurityContextHolder.getContext();
Authentication auth = ctx.getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> roles = auth.getAuthorities();
Object principal = auth.getPrincipal(); // UserDetails object
// Typical pattern in a service
public void updateProfile(ProfileDTO dto) {
String currentUser = SecurityContextHolder.getContext()
.getAuthentication().getName();
// use currentUser for audit logging, ownership checks, etc.
}
@Async, CompletableFuture.supplyAsync()), the SecurityContext is NOT automatically propagated. The new thread has an empty SecurityContext. Fix: use DelegatingSecurityContextExecutor or SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL).
How the FilterChainProxy Works
You don't have one filter chain — you can have many, each matching a different URL pattern. For example, your API routes might use JWT while your admin panel uses form login. Each SecurityFilterChain bean matches requests by URL pattern.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Chain 1: API routes — stateless JWT
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.build();
}
// Chain 2: Actuator — separate access rules
@Bean
@Order(2)
public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().hasRole("ADMIN"))
.httpBasic(Customizer.withDefaults())
.build();
}
}
DelegatingFilterProxy: Bridging Servlet and Spring
The servlet container (Tomcat) manages filters and knows nothing about the Spring application context. DelegatingFilterProxy is registered in the servlet container as a standard filter but delegates all work to a Spring bean (the FilterChainProxy). This bridge is how Spring Security integrates with the servlet lifecycle.
Authentication Architecture
Authentication is the process of verifying who you are. Spring Security's authentication architecture is designed to be extensible — the same interfaces work whether you're authenticating with username/password, JWT, OAuth2, SAML, or a certificate.
The Authentication Flow
@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepo;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepo.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found: " + email));
return org.springframework.security.core.userdetails.User
.withUsername(user.getEmail())
.password(user.getPasswordHash()) // already hashed
.roles(user.getRole().name()) // "ADMIN" → "ROLE_ADMIN"
.accountLocked(user.isLocked())
.accountExpired(false)
.credentialsExpired(false)
.build();
}
}
// Wire it in SecurityConfig
@Bean
public AuthenticationManager authManager(
UserDetailsService uds, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
Custom AuthenticationProvider
For non-standard authentication (API keys, OTP, smart card), implement AuthenticationProvider directly:
// Custom token type
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final String apiKey;
public ApiKeyAuthenticationToken(String apiKey) {
super(null); // no authorities yet
this.apiKey = apiKey;
setAuthenticated(false);
}
@Override public Object getCredentials() { return apiKey; }
@Override public Object getPrincipal() { return apiKey; }
}
// Provider that validates API keys
@Component
public class ApiKeyAuthenticationProvider implements AuthenticationProvider {
private final ApiKeyRepository apiKeyRepo;
@Override
public Authentication authenticate(Authentication auth)
throws AuthenticationException {
String key = (String) auth.getCredentials();
ApiKey stored = apiKeyRepo.findByKeyHash(sha256(key))
.orElseThrow(() -> new BadCredentialsException("Invalid API key"));
if (stored.isExpired()) throw new CredentialsExpiredException("API key expired");
List<GrantedAuthority> authorities =
List.of(new SimpleGrantedAuthority("ROLE_" + stored.getRole()));
ApiKeyAuthenticationToken authenticated =
new ApiKeyAuthenticationToken(key);
authenticated.setAuthenticated(true);
// set authorities on the authenticated token
return new UsernamePasswordAuthenticationToken(
stored.getOwner(), null, authorities);
}
@Override
public boolean supports(Class<?> authentication) {
return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Password Hashing
Passwords must never be stored in plaintext or with reversible encryption. They must be hashed with a slow, salted, one-way algorithm specifically designed for passwords.
BCrypt — the Default
// Register the encoder as a bean
@Bean
public PasswordEncoder passwordEncoder() {
// strength/cost factor: 10-12 is standard for production
// higher = slower = better, but watch CPU on registration endpoints
return new BCryptPasswordEncoder(12);
}
// Usage in registration
@Service
public class AuthService {
private final PasswordEncoder encoder;
private final UserRepository userRepo;
public User register(RegisterRequest req) {
String hashed = encoder.encode(req.getPassword());
// hashed looks like: $2a$12$... (60 chars, includes salt)
return userRepo.save(new User(req.getEmail(), hashed));
}
// Spring calls encoder.matches() automatically during login
// You never need to call it manually in the login flow
}
Choosing a Password Hashing Algorithm
Industry standard. Built-in salt. Cost factor 10–12 for most apps. Available everywhere. Choose this by default.
OWASP recommended. Winner of Password Hashing Competition. Memory-hard — resists GPU and ASIC attacks. Use for high-security applications.
Memory-hard like Argon2. Good alternative. Used by some crypto wallets. Higher memory requirements than BCrypt.
Never use for passwords. Fast by design — billions of hashes/second on GPU. No built-in salt. Trivially cracked with rainbow tables.
DelegatingPasswordEncoder — Supporting Migration
When you need to upgrade your hashing algorithm without forcing all users to reset their passwords, use DelegatingPasswordEncoder. It stores the algorithm identifier as a prefix in the hash.
@Bean
public PasswordEncoder passwordEncoder() {
// Stores hashes as: {bcrypt}$2a$12$..., {argon2}..., {noop}plaintext
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
encoders.put("argon2", new Argon2PasswordEncoder(16, 32, 1, 65536, 10));
encoders.put("noop", NoOpPasswordEncoder.getInstance()); // legacy ONLY
// New passwords use bcrypt
DelegatingPasswordEncoder delegate = new DelegatingPasswordEncoder("bcrypt", encoders);
return delegate;
}
// Migration strategy:
// 1. Change encoder bean to DelegatingPasswordEncoder
// 2. On next login: matches() succeeds with old algorithm
// 3. After successful login, re-encode with new algorithm and save
// Users migrate transparently without password resets
JWT — JSON Web Tokens
JWT is the dominant mechanism for stateless API authentication. Understanding its structure, its security guarantees, and — critically — its limitations is essential for production systems.
JWT Structure
JWT Authentication with Spring Boot
<!-- Spring Boot 3.x — built-in JWT support via oauth2-resource-server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- OR: use the popular JJWT library for manual JWT handling -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.access-token-expiry:900}") // 15 min
private long accessTokenExpirySeconds;
@Value("${app.jwt.refresh-token-expiry:604800}") // 7 days
private long refreshTokenExpirySeconds;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList());
return buildToken(claims, userDetails.getUsername(), accessTokenExpirySeconds);
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(Map.of("type", "refresh"),
userDetails.getUsername(), refreshTokenExpirySeconds);
}
private String buildToken(Map<String, Object> claims, String subject, long expiry) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiry * 1000))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
}
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
// No token — let the chain continue, AuthorizationFilter will deny if needed
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
try {
String username = jwtService.extractUsername(jwt);
// Only authenticate if not already authenticated
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (JwtException e) {
// Invalid signature, expired, malformed — let the request through
// AuthorizationFilter will deny it if the route requires auth
log.warn("Invalid JWT token: {}", e.getMessage());
}
chain.doFilter(request, response);
}
}
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // stateless — no CSRF needed
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
// Add JWT filter before UsernamePasswordAuthenticationFilter
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(
(req, res, e) -> res.sendError(401, "Unauthorized"))
.accessDeniedHandler(
(req, res, e) -> res.sendError(403, "Forbidden")))
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.yourdomain.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Refresh Token Pattern
Access tokens should be short-lived (15 minutes). Refresh tokens are long-lived (days/weeks) and stored securely. This minimizes the damage window if an access token is stolen.
@PostMapping("/api/auth/refresh")
public ResponseEntity<TokenResponse> refreshToken(
@RequestBody RefreshTokenRequest req) {
String refreshToken = req.getRefreshToken();
// 1. Validate JWT structure and expiry
String username = jwtService.extractUsername(refreshToken);
// 2. Check the token exists in DB (enables server-side revocation)
RefreshToken storedToken = refreshTokenRepo
.findByToken(hashToken(refreshToken))
.orElseThrow(() -> new InvalidTokenException("Refresh token not found"));
if (storedToken.isRevoked() || storedToken.isExpired()) {
// Possible token reuse attack — revoke entire family
refreshTokenRepo.revokeAllByUserId(storedToken.getUserId());
throw new SecurityException("Token reuse detected");
}
// 3. Rotate: revoke old refresh token, issue new pair
storedToken.setRevoked(true);
refreshTokenRepo.save(storedToken);
UserDetails user = userDetailsService.loadUserByUsername(username);
String newAccess = jwtService.generateAccessToken(user);
String newRefresh = jwtService.generateRefreshToken(user);
// 4. Store new refresh token hash
refreshTokenRepo.save(new RefreshToken(storedToken.getUserId(),
hashToken(newRefresh)));
return ResponseEntity.ok(new TokenResponse(newAccess, newRefresh));
}
OAuth2 & OpenID Connect
OAuth2 is an authorization framework — it allows a user to grant a third-party application access to resources without sharing their password. OpenID Connect (OIDC) is a thin identity layer on top of OAuth2 that standardizes user authentication.
OAuth2 Roles
The user who owns the data and grants permission to access it.
The application requesting access. Could be your frontend, mobile app, or a backend service.
Issues tokens after authenticating the user. Google, GitHub, Keycloak, Auth0, Okta.
Your Spring Boot API. Validates access tokens and serves protected resources.
OAuth2 Flows
Authorization Code + PKCE is the correct flow for user-facing apps (SPAs, mobile). PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks.
Client Credentials is for machine-to-machine (M2M) communication — no user involved. Microservice A authenticating to microservice B.
# application.yml — service acting as OAuth2 client
spring:
security:
oauth2:
client:
registration:
inventory-service:
client-id: ${INVENTORY_CLIENT_ID}
client-secret: ${INVENTORY_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: inventory:read,inventory:write
provider:
inventory-service:
token-uri: https://auth.yourdomain.com/oauth/token
---
// RestClient with automatic token management
@Bean
public RestClient inventoryClient(OAuth2AuthorizedClientManager manager) {
var interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
return RestClient.builder()
.baseUrl("https://inventory-service/api")
.requestInterceptor(interceptor)
.build();
}
Configure your Spring Boot API as an OAuth2 Resource Server — it validates tokens issued by an external Authorization Server (Google, Keycloak, Auth0).
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Auth server's JWKS endpoint — Spring fetches public keys automatically
jwk-set-uri: https://auth.yourdomain.com/.well-known/jwks.json
# OR: single issuer URI (Spring discovers JWKS automatically via .well-known/openid-configuration)
issuer-uri: https://auth.yourdomain.com
---
// SecurityConfig
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.build();
}
// Map JWT claims to Spring authorities
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles"); // which claim has roles
converter.setAuthorityPrefix("ROLE_"); // prefix each role
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
CSRF & CORS
CSRF — Cross-Site Request Forgery
CSRF attacks trick an authenticated user's browser into making unwanted requests to your API. If a user is logged in to your bank and visits a malicious site, the malicious site can make their browser send a money transfer request.
// For REST APIs using stateless JWT — CSRF protection is not needed
// Reason: CSRF exploits cookies. JWT in Authorization header is same-origin only.
http.csrf(AbstractHttpConfigurer::disable);
// For session-based web apps (server-rendered, or SPA using cookies):
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// Required for Spring Security 6.x — explicitly handle token subscription
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
);
// The frontend must then read the XSRF-TOKEN cookie and send it
// as X-XSRF-TOKEN header on all mutating requests (POST/PUT/DELETE)
CORS — Cross-Origin Resource Sharing
CORS is a browser security mechanism that blocks frontend JavaScript from reading responses from a different origin (domain/port/protocol) than the page was loaded from. Your backend must explicitly allow specific origins.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Explicit allowed origins — never use "*" with credentials
config.setAllowedOrigins(List.of(
"https://app.yourdomain.com",
"https://admin.yourdomain.com"
));
// OR: pattern matching for subdomains
config.setAllowedOriginPatterns(List.of("https://*.yourdomain.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-ID"));
config.setExposedHeaders(List.of("X-Total-Count", "X-Request-ID")); // frontend can read
config.setAllowCredentials(true); // required for cookies/Authorization header
config.setMaxAge(3600L); // preflight cache duration in seconds
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
allowedOrigins("*") with allowCredentials(true). This combination is rejected by browsers (spec violation). More importantly, it would allow any website to make credentialed requests to your API. Always specify explicit origins in production.
Session Management
Session Fixation Attacks
In a session fixation attack, the attacker provides the victim with a known session ID before authentication. When the victim logs in, the session is now authenticated — and the attacker, who knows the session ID, can use it too.
http.sessionManagement(session -> session
// CHANGE_SESSION_ID: creates new session on login — default, recommended
.sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
// For stateless JWT APIs:
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// Limit concurrent sessions — prevents session sharing/theft
.maximumSessions(1)
.maxSessionsPreventsLogin(false) // false = new login invalidates old session
);
Stateless vs Stateful — Architectural Choice
Server stores nothing. Token carries state. Scales horizontally trivially — any server can validate any request. Cannot invalidate individual tokens until expiry without a blocklist.
Session data in database or Redis. Can instantly revoke sessions. Easier to implement security features (force logout, concurrent session limiting). Requires sticky sessions or shared session store.
Rate Limiting & API Security
Rate limiting prevents brute force attacks, credential stuffing, denial-of-service, and API abuse. It's one of the most overlooked production security controls.
Bucket4j — In-Process Rate Limiting
<!-- pom.xml -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.7.0</version>
</dependency>
// Rate limit filter — 20 requests per minute per IP
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RateLimitFilter extends OncePerRequestFilter {
// ConcurrentHashMap for in-memory; swap for Redis/Hazelcast in distributed setup
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String key = getKey(req);
Bucket bucket = buckets.computeIfAbsent(key, k -> createBucket());
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
res.setHeader("X-Rate-Limit-Remaining",
String.valueOf(probe.getRemainingTokens()));
chain.doFilter(req, res);
} else {
res.setStatus(429);
res.setHeader("X-Rate-Limit-Retry-After-Seconds",
String.valueOf(probe.getNanosToWaitForRefill() / 1_000_000_000));
res.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
}
}
private Bucket createBucket() {
return Bucket.builder()
.addLimit(Bandwidth.builder()
.capacity(20)
.refillGreedy(20, Duration.ofMinutes(1))
.build())
.build();
}
private String getKey(HttpServletRequest req) {
// Use authenticated username if available, otherwise IP
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() &&
!(auth instanceof AnonymousAuthenticationToken)) {
return "user:" + auth.getName();
}
return "ip:" + req.getRemoteAddr();
}
}
Login Endpoint Hardening
@PostMapping("/api/auth/login")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest req,
HttpServletRequest httpReq) {
String ip = getClientIp(httpReq);
String key = "login:" + ip;
// 1. Check failed attempt count in Redis
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
if (attempts != null && attempts >= 5) {
throw new TooManyAttemptsException("Account temporarily locked. Try in 15 minutes.");
}
try {
// 2. Attempt authentication
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.getEmail(), req.getPassword()));
// 3. Success — clear failure count
redisTemplate.delete(key);
UserDetails user = (UserDetails) auth.getPrincipal();
String access = jwtService.generateAccessToken(user);
String refresh = jwtService.generateRefreshToken(user);
// 4. Store refresh token hash
saveRefreshToken(user.getUsername(), refresh);
return ResponseEntity.ok(new TokenResponse(access, refresh));
} catch (BadCredentialsException e) {
// 5. Increment failure count with TTL
redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofMinutes(15));
// Return generic message — don't leak whether email exists
throw new BadCredentialsException("Invalid credentials");
}
}
Common Attack Vectors & Defenses
Production Security Pitfalls
Attackers change the JWT header algorithm to "alg":"none" and remove the signature. Older JWT libraries accepted these. Also: if your server uses RS256 (asymmetric), an attacker could change the header to HS256 and sign with the public key (which is public!). Fix: explicitly specify and validate the algorithm in your JWT parser. Never accept "none".
// JJWT 0.12 — explicitly require HS256
Jwts.parser()
.requireAlgorithm(Jwts.SIG.HS256) // throws if alg differs
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
localStorage is accessible to any JavaScript on the page — including injected scripts (XSS). A single XSS vulnerability anywhere in your frontend exposes all stored tokens. Better option: store access tokens in memory (JavaScript variable) and refresh tokens in httpOnly secure cookies. httpOnly cookies cannot be read by JavaScript — only sent automatically on requests.
Exposing sequential database IDs (/api/orders/1234) lets attackers enumerate resources. Always verify ownership in service methods, not just authentication. @PreAuthorize("@orderSecurity.isOwner(#id, authentication)") is the fix — never trust that "they're authenticated" means "they own this resource".
Using .anyRequest().permitAll() as a catch-all instead of .anyRequest().authenticated(). This means any new endpoint added to the application is public by default until someone explicitly secures it. Default-deny (anyRequest().authenticated()) is the safe default — explicitly open what needs to be public.
Using a short or predictable JWT secret ("secret", "myapp123"). HMAC-SHA256 with a weak secret can be brute-forced. Use at minimum a 256-bit (32 byte) cryptographically random secret. Generate with: openssl rand -base64 32. Store in environment variable, never in source code.
Setting access token expiry to hours or days. If a token is stolen, the attacker has that entire window. 15 minutes is the industry standard for access tokens. Use refresh tokens for long-term access — they can be rotated and revoked. Short access tokens are a defense-in-depth measure: even if intercepted, the damage window is minimal.
Security Headers
Spring Security sets sensible security headers by default. Know what they are and why.
http.headers(headers -> headers
// Prevent clickjacking — disallow embedding in iframes
.frameOptions(frame -> frame.deny())
// Force HTTPS for 1 year — includes subdomains
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000))
// Prevent MIME type sniffing
.contentTypeOptions(Customizer.withDefaults())
// Referrer policy — don't leak full URL to external sites
.referrerPolicy(ref -> ref.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
// Content Security Policy — restrict script sources
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; " +
"script-src 'self' 'nonce-{NONCE}'; " +
"style-src 'self' https://fonts.googleapis.com; " +
"img-src 'self' data:; " +
"frame-ancestors 'none'"))
);
Method Security — Advanced Patterns
Custom Security Expressions
// Extend default expressions with custom methods
public class CustomMethodSecurityExpressionRoot
extends SecurityExpressionRoot
implements MethodSecurityExpressionOperations {
private final TenantService tenantService;
private Object filterObject;
private Object returnObject;
public CustomMethodSecurityExpressionRoot(Authentication auth,
TenantService tenantService) {
super(auth);
this.tenantService = tenantService;
}
// Custom expression: @PreAuthorize("belongsToTenant(#tenantId)")
public boolean belongsToTenant(Long tenantId) {
String currentUser = getAuthentication().getName();
return tenantService.userBelongsToTenant(currentUser, tenantId);
}
// Custom expression: @PreAuthorize("hasPlan('ENTERPRISE')")
public boolean hasPlan(String planName) {
return getAuthentication().getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("PLAN_" + planName));
}
@Override public Object getFilterObject() { return filterObject; }
@Override public void setFilterObject(Object o) { filterObject = o; }
@Override public Object getReturnObject() { return returnObject; }
@Override public void setReturnObject(Object o) { returnObject = o; }
@Override public Object getThis() { return this; }
}
// Register the custom expression handler
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler() {
@Override
protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
Authentication auth, MethodInvocation mi) {
return new CustomMethodSecurityExpressionRoot(auth, tenantService);
}
};
return handler;
}
Complete @PreAuthorize Example — Multi-Tenant API
@Service
public class ProjectService {
// Only project members can view
@PreAuthorize("@projectSecurity.isMember(#projectId, authentication) " +
"or hasRole('ADMIN')")
public Project getProject(Long projectId) { ... }
// Only project OWNER or admin can delete
@PreAuthorize("@projectSecurity.isOwner(#projectId, authentication) " +
"or hasRole('ADMIN')")
@Transactional
public void deleteProject(Long projectId) { ... }
// Return only projects the user can see (post-filter)
@PostFilter("@projectSecurity.isMember(filterObject.id, authentication) " +
"or hasRole('ADMIN')")
public List<Project> getAllProjects() {
return projectRepo.findAll(); // Spring filters the result
}
}
@Component("projectSecurity")
@RequiredArgsConstructor
public class ProjectSecurityService {
private final ProjectMemberRepository memberRepo;
public boolean isMember(Long projectId, Authentication auth) {
return memberRepo.existsByProjectIdAndUserEmail(projectId, auth.getName());
}
public boolean isOwner(Long projectId, Authentication auth) {
return memberRepo.existsByProjectIdAndUserEmailAndRole(
projectId, auth.getName(), "OWNER");
}
}
UserDetailsService — Spring Security will call it on token validation and throw if the account is locked. Changing the JWT secret invalidates ALL users' tokens — avoid this except as a last resort.
Interview Questions
@Secured is Spring-specific, simple role checking: @Secured("ROLE_ADMIN") — no SpEL. @RolesAllowed is the JSR-250 standard annotation — same capability as @Secured, works outside Spring. @PreAuthorize is the most powerful — it evaluates SpEL expressions before method execution, supports complex conditions (hasRole() and @bean.method(#param)), access to method arguments, and custom expression roots. @PostAuthorize evaluates after execution (can inspect the return value). In modern Spring Boot applications, prefer @PreAuthorize for its expressiveness.