Spring Security
Authentication vs Authorization
Two distinct concerns frequently conflated in design discussions and a common interview opener.
| Authentication | Authorization | |
|---|---|---|
| Question answered | “Who are you?” | “What are you allowed to do?” |
| When it happens | Once, at login/token validation | Every protected resource access, after identity is established |
| Spring Security abstraction | AuthenticationManager → Authentication object |
AccessDecisionManager/AuthorizationManager → GrantedAuthority checks |
| Failure response | 401 Unauthorized |
403 Forbidden |
Architect-level point: a 403 on a request with no credentials at all is a common API contract bug — if identity was never established, the correct response is 401; 403 should only fire when identity is known but insufficiently privileged. This distinction is worth calling out explicitly in API design reviews since many teams conflate the two status codes.
// Authentication: establishing WHO
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password));
// Authorization: checking WHAT they can do, post-authentication
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(Long id) { ... }
Security Filter Chain
Spring Security is implemented entirely as a chain of servlet Filters registered ahead of DispatcherServlet — tying directly back to the Filters-vs-Interceptors distinction: security is a filter-level concern precisely because it must run before any controller/handler logic, regardless of which controller would eventually be matched.
Request → SecurityContextPersistenceFilter → ... → UsernamePasswordAuthenticationFilter
→ ExceptionTranslationFilter → FilterSecurityInterceptor/AuthorizationFilter → DispatcherServlet
Key filters in the default chain (order matters — this is a frequent whiteboard question):
| Filter | Responsibility |
|---|---|
SecurityContextHolderFilter |
Loads/clears SecurityContext for the thread per request |
UsernamePasswordAuthenticationFilter |
Handles form-login POST, builds Authentication |
BasicAuthenticationFilter |
Handles HTTP Basic header |
BearerTokenAuthenticationFilter / custom JWT filter |
Validates bearer tokens |
ExceptionTranslationFilter |
Converts AuthenticationException/AccessDeniedException into 401/403 responses |
AuthorizationFilter (modern, replaces FilterSecurityInterceptor) |
Final authorization decision against the request |
Modern configuration (Spring Security 6+, lambda DSL — the WebSecurityConfigurerAdapter approach is deprecated):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // typically disabled for stateless JWT APIs, not for browser session apps
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**", "/actuator/health").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(401))
.accessDeniedHandler((req, res, e) -> res.sendError(403)));
return http.build();
}
}
Architect-level nuance — CSRF: disable CSRF only for genuinely stateless, token-authenticated APIs with no browser-cookie-based session. If any part of the app uses session/cookie auth (even alongside JWT for another path), leaving CSRF protection off opens it to cross-site request forgery — a real-world misconfiguration that shows up in security audits more often than it should.
JWT Authentication
Stateless authentication: the server issues a signed token after login; subsequent requests carry it (typically Authorization: Bearer <token>) and are validated without server-side session storage — a natural fit for horizontally scaled microservices where session affinity/replication would otherwise be a burden.
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtService.parseAndValidate(token);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, extractAuthorities(claims));
SecurityContextHolder.getContext().setAuthentication(authToken);
} catch (JwtException e) {
SecurityContextHolder.clearContext(); // invalid/expired token — leave unauthenticated, let downstream 401
}
}
chain.doFilter(req, res);
}
}
Token structure: header.payload.signature — header declares algorithm, payload carries claims (sub, exp, iat, custom claims like roles), signature is HMAC (HS256, shared secret) or RSA/ECDSA (RS256/ES256, public/private key pair).
Architect decision — HS256 vs RS256:
| HS256 | RS256 | |
|---|---|---|
| Key | Single shared secret | Private key signs, public key verifies |
| Use case | Single service issuing and validating its own tokens | Multiple services need to verify tokens without being able to issue them (microservices architecture) |
| Risk if leaked | Secret leak = forgeable tokens anywhere it’s used | Public key leak is harmless by design; only private key compromise is catastrophic |
In a microservices estate (relevant to your Smith & Nephew-scale integration context), RS256 is the standard choice — an identity provider holds the private key, downstream services only need the public key to verify, with zero ability to mint tokens themselves.
Refresh token pattern: short-lived access token (minutes) + longer-lived refresh token (days), refresh token stored more restrictively (httpOnly cookie or secure storage) and exchanged via a dedicated /refresh endpoint — limits the blast radius of a leaked access token without forcing frequent re-logins.
Common pitfalls to flag in review: no expiry validation, storing JWTs in localStorage (XSS-exposed) instead of httpOnly cookies for browser clients, and missing signature algorithm allow-listing (the classic “alg: none” JWT forgery vulnerability if the library isn’t configured to reject it).
Method-Level Security
Authorization enforced at the method boundary rather than (or in addition to) the URL boundary — necessary when authorization depends on the data, not just the route.
@Configuration
@EnableMethodSecurity // replaces @EnableGlobalMethodSecurity in Spring Security 6
public class MethodSecurityConfig {}
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication.name)")
public Order getOrder(Long orderId) { ... }
@PreAuthorize("hasAuthority('ORDER_DELETE')")
public void deleteOrder(Long orderId) { ... }
@PostAuthorize("returnObject.customerId == authentication.name")
public Order findOrder(Long orderId) { ... } // checked AFTER method executes, against the return value
}
| Annotation | Evaluated | Use case |
|---|---|---|
@PreAuthorize |
Before method execution | Standard gate — role/authority/expression check on input params |
@PostAuthorize |
After method execution, against return value | When the authorization decision depends on data only available in the result (e.g., “is this the caller’s own record”) |
@PreFilter |
Before, filters a collection argument | Strip unauthorized elements from an incoming collection |
@PostFilter |
After, filters a collection return value | Strip unauthorized elements from a returned collection |
Implementation, again, is the same AOP-proxy mechanism as @Transactional — meaning the self-invocation pitfall applies identically: calling an @PreAuthorize-annotated method via this.method() from within the same class bypasses the check entirely. This is genuinely one of the more dangerous gotchas in Spring, since a missed authorization check fails silently (the call just succeeds) rather than throwing — worth explicitly testing for in security-sensitive code paths.
Architect-level guidance: combine URL-level (authorizeHttpRequests) coarse-grained gating with method-level fine-grained checks — URL rules establish “you must be authenticated to even reach this controller,” method-level rules establish “and specifically, you must own this record or hold this authority.” Relying on URL-level checks alone is insufficient wherever authorization is data-dependent.
Password Encoding
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength/cost factor — tune based on hardware/latency budget
}
@Service
public class UserRegistrationService {
private final PasswordEncoder passwordEncoder;
public void register(String rawPassword) {
String hash = passwordEncoder.encode(rawPassword); // never store rawPassword
userRepository.save(new User(hash));
}
public boolean verify(String rawPassword, String storedHash) {
return passwordEncoder.matches(rawPassword, storedHash); // never decode/compare directly
}
}
Why BCrypt (or Argon2/SCrypt) over plain hashing (SHA-256, MD5): these are adaptive, intentionally slow hashing algorithms with built-in salting — designed specifically to resist brute-force and rainbow-table attacks via a tunable work factor. A fast general-purpose hash (SHA-256) is the wrong tool for passwords precisely because its speed, which is desirable elsewhere, makes brute-forcing cheap.
| Algorithm | Notes |
|---|---|
| BCrypt | Industry default, Spring Security’s BCryptPasswordEncoder, well-understood, tunable cost factor |
| Argon2 | Winner of the Password Hashing Competition, memory-hard (resists GPU/ASIC cracking better than BCrypt) — increasingly preferred for new systems |
| SCrypt | Also memory-hard, less common in Spring ecosystems than Argon2 |
DelegatingPasswordEncoder |
Spring’s default — prefixes stored hashes with {bcrypt}/{argon2} etc., allowing graceful algorithm migration over time without breaking existing stored hashes |
Architect-level point: PasswordEncoder.matches() always — never decode and compare strings manually, and never log raw passwords even at DEBUG level (a surprisingly common audit finding). Use Slf4j/logback masking patterns for any field plausibly containing credentials.
OAuth2 Basics
OAuth2 is an authorization delegation framework — it lets a client access a resource on a user’s behalf without ever handling the user’s actual credentials. OpenID Connect (OIDC) layers authentication (identity, via the id_token) on top of OAuth2, which by itself only proves authorization, not identity — a frequently confused distinction.
Core roles:
| Role | Description |
|---|---|
| Resource Owner | The end user |
| Client | The application requesting access (your service) |
| Authorization Server | Issues tokens after authenticating the user (Keycloak, Okta, Auth0, Cognito, Azure AD) |
| Resource Server | Hosts the protected API, validates incoming tokens |
Grant types relevant to architecture decisions:
| Grant | Use case |
|---|---|
| Authorization Code (+ PKCE) | Standard for web/mobile apps with a user present — the only grant recommended for browser-based flows today |
| Client Credentials | Service-to-service, no user context (machine-to-machine API access) |
| Refresh Token | Exchanging an expired access token for a new one without re-authenticating the user |
| ~~Implicit~~ | Deprecated — tokens exposed in URL fragments; superseded by Authorization Code + PKCE even for SPAs |
| ~~Resource Owner Password Credentials~~ | Deprecated — requires the client to handle raw credentials, defeats OAuth2’s core purpose |
Spring Boot as a Resource Server (validating tokens issued elsewhere — the common microservices pattern):
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.acme.com/realms/orders
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
Spring Security auto-fetches the issuer’s JWKS (public keys) and validates incoming bearer tokens’ signature, expiry, and issuer — no manual JWT parsing filter required, unlike the custom JWT example above (this is the path to prefer whenever an external, standards-compliant identity provider is in play, rather than hand-rolling token issuance/validation).
Architect decision point — build vs delegate: hand-rolled JWT issuance (as shown in the JWT section) is appropriate for a single self-contained service issuing its own tokens. The moment there’s more than one service, or a need for SSO, social login, MFA, or compliance certification, the architecturally sound move is delegating to a dedicated identity provider (Keycloak/Okta/Cognito/Azure AD) speaking standard OAuth2/OIDC, with each service acting purely as a resource server validating tokens — not reinventing identity management per service.