Spring Security Fundamentals: Auth, Authz, and Filter Chains
Learn Spring Security core concepts: authentication, authorization, and the filter chain with practical examples.
Learn Spring Security core concepts: authentication, authorization, and the filter chain with practical examples. The guide uses practical examples to explain when to use spring security, when not to use spring security and shows how to apply the ideas in a Spring Boot project. It closes with common pitfalls and production checks so you can apply the pattern with fewer surprises.
Spring Security Fundamentals
Spring Security is one of those libraries that most Java developers encounter early and spend years later wishing they had understood sooner. It handles the unglamorous work of authentication and authorization so you do not have to reinvent the wheel badly. If you have worked with Spring Boot, you have already been touched by it whether you realized it or not.
This post covers the three core ideas: authentication (proving you are who you say you are), authorization (deciding what you get to do), and the filter chain (the machinery underneath). We will look at when to use Spring Security and when to reach for something simpler, walk through failure modes that bite teams in production, and give you code you can actually use.
When to use Spring Security
Introduction
Spring Security provides the request-processing machinery for proving identity, enforcing permissions, and protecting Spring applications. This guide builds a working model of authentication, authorization, and the security filter chain, then applies it to configuration, failure handling, and decisions about when the framework is appropriate.
When not to use Spring Security
Skip Spring Security when you are building a purely public site with no authentication requirements. If you only need basic authentication headers passed through to a downstream service, a simple filter works. When your application delegates entirely to a third-party identity provider and you never validate credentials locally, Spring Security adds overhead without value.
Frameworks that ship their own security solution are another case where you might not want it. JHipster wraps Spring Security, for example. Camunda has its own model. In those cases, fighting the framework is its own problem.
If you are prototyping and need to move fast, a custom filter might serve you better. Spring Security has a learning curve. For throwaway projects, that investment rarely makes sense.
Spring Security filter chain architecture
The security filter chain is where everything comes together. Spring Security deploys a chain of servlet filters, each responsible for a specific piece of the security puzzle. The order matters because it determines how requests get processed before they reach your controller.
graph TD
A["HTTP Request"] --> B["Security Context Persistence Filter"]
B --> C["Logout Filter"]
C --> D["Username Password Authentication Filter"]
D --> E["Default Login Page Filter"]
E --> F["Basic Authentication Filter"]
F --> G["Request Cache Filter"]
G --> H["Security Context HolderAware Request Wrapper"]
H --> I["Authorization Filter"]
I --> J["Exception Translation Filter"]
J --> K["Filter Security Interceptor"]
K --> L["Your Controller / Resource"]
The chain starts with filters that handle session management and authentication extraction, moves through authorization checks, and ends with the FilterSecurityInterceptor which either lets the request through or throws a security exception. When an exception bubbles up, the ExceptionTranslationFilter catches it and returns an appropriate HTTP response.
Each filter in the chain has a specific Order value. You can inspect the default order by enabling debug logging:
logging.level.org.springframework.security=DEBUG
Custom filters can be inserted at specific positions using HttpSecurity.addFilterBefore() or addFilterAfter(). If you need to replace a built-in filter entirely, use addFilterAt() which places your filter at the same position as the one you are replacing.
Failure scenarios
Spring Security misconfigurations send plenty of teams to incident calls. Here are the ones that show up most often.
CSRF protection gone wrong
Spring Security enables CSRF protection by default. This means your forms need to include a CSRF token, and your AJAX calls need to send it as a header. Teams that forget this hit 403 errors in production.
The classic symptom: everything works in testing, then users report mysterious failures after deploying a new page or SPA.
Fix it by including the token in your forms:
<form method="post" action="/submit">
<input type="hidden" name="_csrf" value="${_csrf.token}" />
<!-- form fields -->
</form>
Or for SPAs, retrieve the token from the cookie that Spring Security sets and send it as a header:
const token = document.cookie.match(/^.*csrftoken=(.*).*$/)[1];
fetch("/api/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": token,
},
body: JSON.stringify(data),
});
If you genuinely do not need CSRF protection (for example, a purely stateless API with no session), disable it explicitly:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable());
return http.build();
}
Session fixation attacks
Session fixation happens when an attacker sets a session ID on a victim before they log in. After the victim authenticates, the attacker can hijack the established session.
Spring Security protects against this by default in newer versions. When a user authenticates, the framework creates a new session and invalidates the old one. You can control this behavior:
http
.sessionManagement(session -> session
.sessionFixation().newSession()
);
Available strategies include migrateSession (copies attributes to a new session), newSession (fresh session without attributes), none (no protection), and changeSessionId (changes the ID without invalidating).
Broken access control
This is the most common class of security vulnerability. Access control breaks when developers rely only on frontend checks, forget method-level security on service layers, or misconfigure path-based authorization.
// RISKY: assumes controller handles everything
@PostMapping("/admin/delete-user")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
// SAFER: explicit method-level security
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/admin/delete-user/{userId}")
public void deleteUser(@PathVariable Long userId) {
userRepository.deleteById(userId);
}
Always validate authorization at the service layer, not just at the controller. Defense in depth matters.
Authentication mechanisms comparison
Choosing an authentication mechanism shapes your entire security architecture. Here is how the main options compare.
| Feature | HTTP Basic | Form Login | JWT Tokens | OAuth 2.0 |
|---|---|---|---|---|
| Stateful/Stateless | Stateless | Stateful | Stateless | Either |
| Credential Storage | Server-side | Server-side | None (token only) | Identity provider |
| Session Management | None | Servlet session | None | Provider handles |
| Scalability | Easy (stateless) | Hard (session affinity) | Easy | Depends on setup |
| CSRF Protection | Not needed | Required | Not needed | Built into flow |
| Token Expiry | N/A | N/A | Configurable | Configurable |
| Refresh Flow | N/A | N/A | Manual | Built-in |
| Best For | Internal APIs | Web apps | Mobile + SPAs | SSO, third-party login |
| Security Level | Low (base64 only) | Medium-High | High (with HTTPS) | High |
HTTP Basic sends credentials with every request, base64 encoded. It is fine over HTTPS for internal tooling but terrible for public-facing APIs. Form login gives you full control over the login page and session lifecycle. JWT tokens shift the session problem to the client and require careful handling of expiry and refresh. OAuth 2.0 delegates identity to a provider and handles the complexity of refresh tokens and SSO. For a deeper look at OAuth and OIDC patterns, see the OAuth/OIDC guide.
Session vs stateless authentication
The session versus stateless debate comes up constantly. Here is the practical breakdown.
Session-based (stateful)
The server stores session data and sends a session ID cookie to the client. Every request sends the cookie back, and the server looks up the session.
Pros:
- Sessions can store arbitrary user data server-side
- Revoking access is instant: invalidate the session
- Built-in rotation on login prevents session fixation
Cons:
- Requires sticky sessions or shared session store in a distributed setup
- Session storage takes memory or database space
- Harder to scale horizontally
Token-based (stateless)
The server signs a token (typically JWT) containing user claims. The token is stored client-side and sent with every request.
Pros:
- No server-side session storage needed
- Scales trivially since any server can validate the token
- Works across domains and mobile clients easily
Cons:
- Token revocation is hard without a blocklist or short expiry
- Sensitive data in the token payload is base64 encoded, not encrypted
- No automatic cleanup on logout
For most web applications with a distributed architecture, stateless with short-lived JWTs and refresh token rotation hits a good balance. For simpler single-server applications or when you need instant revocation, sessions win.
Implementation snippets
Here is how to wire up Spring Security in a typical Spring Boot application.
Basic SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/css/**", "/js/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
.httpBasic(basic -> {}); // enables HTTP Basic too
return http.build();
}
}
Custom UserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.map(user -> org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().toArray(new String[0]))
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
Method-level security
Enable method security and use annotations on your service layer:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
// enables @PreAuthorize, @PostAuthorize, @Secured
}
@Service
public class PaymentService {
@PreAuthorize("hasRole('USER')")
public Account balance(Long accountId) {
// every caller must have USER role
}
@PreAuthorize("hasRole('ADMIN') or #account.owner == authentication.name")
public void transfer(Long accountId, BigDecimal amount) {
// ADMIN can transfer anything; users only their own accounts
}
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void generateReport() {
// requires either ADMIN or MANAGER role
}
}
Observability checklist
Security events need to be visible. Here is what to log and monitor.
Security events to log
Track these events in your application logs or a dedicated audit system:
- Authentication success: username, timestamp, source IP, session ID
- Authentication failure: attempted username, timestamp, source IP, failure reason
- Authorization failures: user, resource attempted, required permission, timestamp
- Logout events: user, timestamp, session duration
- Account lockout: which account, lockout duration, source IP
Spring Security emits these through ApplicationEvent subclasses. Listen with an ApplicationListener:
@Component
public class SecurityEventListener {
private static final Logger log = LoggerFactory.getLogger(SecurityEventListener.class);
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
log.info("User {} authenticated from {}",
event.getAuthentication().getName(),
event.getAuthentication().getDetails());
}
@EventListener
public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
log.warn("Authentication failed for user {}: {}",
event.getAuthenticationAttempted(),
event.getException().getMessage());
}
@EventListener
public void onAccessDenied(AccessDeniedEvent event) {
log.warn("Access denied for user {} on resource {}",
event.getAuthentication().getName(),
event.getAccess().getResource());
}
}
Audit trail best practices
- Log to a separate appender or service that is harder for attackers to tamper with
- Include correlation IDs so you can trace requests across services
- Set up alerts for repeated failed logins from the same IP
- Monitor for unusual access patterns outside business hours
- Retain audit logs per your compliance requirements (PCI, SOC2, etc.)
Security notes
Password encoding
Never store passwords in plain text. Spring Security requires a PasswordEncoder:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
BCrypt is the default and the right choice for most cases. It is slow by design (making brute-force attacks harder) and includes a salt. If you need something else, Argon2 is a good modern alternative via the argon2-password-encoder library. For more on securing your application layers, also review the Spring Boot security testing guide.
Always encode passwords when creating or updating users:
UserDetails user = User.builder()
.username("jane")
.password(passwordEncoder.encode("secretPassword"))
.roles("USER")
.build();
Secure defaults
Spring Security ships with sensible defaults, but a few deserve your attention:
- CSRF protection is enabled by default. Only disable it for stateless APIs where you understand the implications.
- Session fixation protection is enabled by default. Keep it on.
- FrameOptions is set to DENY by default, preventing clickjacking via iframe embedding. Change it only if you need to embed your app in frames from the same origin.
- HTTPS redirect is not automatic. Force HTTPS in production via your deployment configuration or
RequireHttpsRedirectFilter.
Test your configuration with the security headers module:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000))
);
return http.build();
}
Common Pitfalls / Anti-Patterns
- Disabling security entirely during development and forgetting to re-enable it before shipping. Use Spring profiles to keep security active everywhere.
- Missing role prefixes. Spring Security expects
ROLE_USER, not justUSER. Check your database seeds and JWT claims match. - Storing sensitive data in JWT payload. The payload is base64 encoded but not encrypted. Put only non-sensitive claims there.
- Not handling expired sessions gracefully. Users hate hitting a 403 and getting no feedback.
- Forgetting to validate incoming request data in controllers because you assumed authentication handled everything. Authentication is not authorization.
- Exposing stack traces in production. Always configure an error page that does not leak implementation details.
- Using default in-memory users in production. Remove any
UserDetailsManagerauto-configuration if you have your own UserDetailsService.
Trade-Off Table
Here is how the four main authentication approaches stack up against each other:
| Feature | HTTP Basic | Form Login | JWT Tokens | OAuth 2.0 |
|---|---|---|---|---|
| Stateful vs Stateless | Stateful (server session) | Stateful (server session) | Stateless (token in request) | Stateless (token-based) |
| Credential Storage | In-memory or UserDetailsService | In-memory or UserDetailsService | Credentials validated at login; token issued | Credentials validated by identity provider |
| Session Management | Server-managed HTTP session | Server-managed HTTP session | No session; token included in each request | No session; token managed by client |
| Scalability | Hard to scale horizontally without sticky sessions | Hard to scale horizontally without sticky sessions | Scales easily (stateless tokens) | Scales easily (stateless tokens) |
| CSRF Protection Needed | Yes (if using sessions) | Yes (form submission) | No (stateless) | No (stateless) |
| Token Expiry | Session timeout (server-side) | Session timeout (server-side) | Configurable; requires refresh mechanism | Configurable; handled by identity provider |
| Best Use Cases | Service-to-service APIs, internal tools, testing | Traditional web apps with server-rendered pages | SPAs, mobile apps, microservices, stateless APIs | Third-party integrations, SSO, social login, federated identity |
When to Use Each
- HTTP Basic: Fit for internal APIs, debugging during development, or service-to-service calls over HTTPS.
- Form Login: Works well for traditional server-rendered apps where session-based auth makes sense and you need session fixation protection.
- JWT Tokens: A good match for SPAs, mobile apps, or microservices that need to scale out without dealing with shared sessions.
- OAuth 2.0: The right choice when you are building third-party integrations, adding social login, or running SSO across several services.
Quick Recap Checklist
Use this as a deployment gate for anything touching authentication or authorization.
- CSRF token included in all state-mutating requests
- CSRF disabled only for intentionally stateless endpoints
- Session fixation protection active
- Passwords encoded with BCrypt or Argon2
- Method-level security annotations reviewed for sensitive operations
- Security event listener logging authentication success and failure
- Authorization checks exist at both controller and service layers
- Security headers configured (HSTS, CSP, X-Frame-Options)
- No default or test credentials in production builds
- Logged-out users cannot reuse the same session
- Error responses do not expose internal implementation details
Interview Questions
Spring Security registers a chain of servlet filters that process every request before it reaches your controller. Each filter has a specific order and responsibility: some extract credentials, others check session state, and the final ones enforce authorization.
The order matters because filters chain sequentially. A request first hits the SecurityContextPersistenceFilter which loads any existing session data, then moves through authentication filters like UsernamePasswordAuthenticationFilter or BasicAuthenticationFilter, and finally reaches the FilterSecurityInterceptor which either allows the request through or throws a security exception.
If you need to insert custom logic, you choose where in the chain your filter goes using addFilterBefore(), addFilterAfter(), or addFilterAt(). Putting something in the wrong position can bypass critical checks.
Authentication answers the question: "Who are you?" It verifies identity against a credential store, whether that is a database, LDAP, or an OAuth provider. In Spring Security, this produces an Authentication object stored in the SecurityContext that holds the principal, credentials, and granted authorities.
Authorization answers: "What are you allowed to do?" It happens after authentication and decides whether an authenticated principal can access a specific resource or execute a particular action. Spring Security handles this through the access control rules you configure in HttpSecurity for URL paths, or through method-level annotations like @PreAuthorize for fine-grained control.
The key insight is that authentication must come first. A request without a valid authentication token cannot be meaningfully authorized because you do not know who is making the request.
You need a filter that intercepts requests, extracts the JWT from the Authorization header, validates it, and populates the SecurityContext. The filter goes before the UsernamePasswordAuthenticationFilter in the chain.
The token validation checks the signature using a shared secret or public key, verifies expiry, and extracts claims to build an Authentication object. Once set in the SecurityContext, Spring Security treats it like any other authenticated user for authorization purposes.
On the logout side, since there is no server-side session to invalidate, you either keep JWTs short-lived (which means frequent re-authentication) or maintain a server-side token blocklist. Most production systems do both.
Cross-Site Request Forgery tricks a victim's browser into sending an authenticated request to a target site without the victim's consent. If a user is logged into your bank and visits a malicious page, that page can trigger a transfer request using the user's session cookie.
Spring Security's CSRF protection works by synchronizing a token between the server and every state-mutating form or AJAX request. When a form submits, the server checks that the request's token matches the expected value. An attacker cannot forge this token because it is tied to the user's session.
For HTML forms, you include a hidden input with the token. For JavaScript clients, you read the token from a cookie (which the browser automatically sends) or retrieve it via an endpoint, then send it as a header with your requests.
@PreAuthorize and related annotations ( @PostAuthorize, @Secured, @RolesAllowed) intercept method invocations at the AOP level. You can check complex conditions using SpEL expressions against the security context, method arguments, or returned values.
URL-based authorization (what you configure in HttpSecurity) handles coarse-grained access at the web layer. Method-level security adds defense in depth and handles cases where code is called from multiple entry points or where authorization depends on argument values, not just the URL.
A typical pattern: HttpSecurity rules as the first gate keeping unauthenticated users away from sensitive URL paths, then @PreAuthorize on service methods enforcing business rules like "users can only access their own records." This way, even if a controller path is misconfigured, the service layer still protects the operation.
The SecurityContext holds the Authentication object for the current thread. The SecurityContextHolder is the utility class that manages this context using a ThreadLocal by default, meaning each thread has its own isolated security context.
When a request comes in, the SecurityContextPersistenceFilter (first in the chain) loads any existing session data and populates the SecurityContextHolder. Authentication filters then modify this context as credentials are validated. The context remains available throughout the request processing and is cleared at the end of the request.
In a Spring MVC controller, you can access the current user via SecurityContextHolder.getContext().getAuthentication(). In a service method, the same call works because the context is thread-local. This design means you do not need to pass authentication objects through method parameters.
UserDetails is the contract for what Spring Security needs to know about a user: username, password, authorities, and account status flags (expired, locked, disabled). It is what gets stored in the Authentication object after authentication succeeds.
UserDetailsService is the contract for loading user data from somewhere. It has a single method loadUserByUsername(String username) that returns a UserDetails object. This is the adapter pattern: you implement UserDetailsService to connect Spring Security to your user store, whether that is a database, LDAP, or an external service.
The separation lets you reuse the UserDetails interface across different authentication mechanisms. A UserDetails object can come from a database via JDBC, from JPA, from an in-memory map, or from LDAP, and Spring Security works with it identically once loaded.
Session fixation occurs when an attacker visits a site, gets a session ID assigned, then tricks a victim into authenticating with that same session ID. After the victim logs in, the attacker knows the session ID and can hijack the authenticated session.
Spring Security prevents this by creating a new session ID after authentication. The framework provides several strategies: migrateSession copies attributes to a new session, newSession starts fresh, changeSessionId uses the same session but rotates the ID, and none disables protection.
For most applications, changeSessionId (the default in newer versions) is the right choice because it preserves session attributes while preventing fixation. Never use none in production unless you have a specific reason and understand the risk.
CORS runs before Spring Security in the filter chain. If CORS rejects a request due to missing or invalid Origin headers, the request never reaches Spring Security filters. This means your security configuration does not apply to preflight requests.
The common mistake is forgetting to configure CORS at all, which causes browser preflight failures even for legitimate requests. You need both a CORS configuration (which origin(s) to allow) and a Spring Security configuration that permits the CORS headers through.
With Spring Security, you typically configure CORS in the SecurityFilterChain using cors() which integrates with the Spring MVC CORS configuration. The key is ensuring your Spring MVC @CrossOrigin annotations or WebMvcConfigurer CORS settings and your Spring Security settings agree on which origins are allowed.
hasAuthority() takes the exact authority string as written. If your granted authority is "ROLE_ADMIN", then hasAuthority("ROLE_ADMIN") returns true.
hasRole() automatically prefixes "ROLE_" to the argument. So hasRole("ADMIN") checks for "ROLE_ADMIN". This is a convenience feature to avoid hardcoding the prefix everywhere.
hasAnyRole() is the same as hasRole() but accepts a varargs list, checking if the user has any of the roles. Mixing hasRole() and hasAuthority() in the same expression works, but you must be consistent about whether you are storing roles with or without the "ROLE_" prefix in your UserDetails.
You add the OAuth 2.0 Resource Server dependency and configure a SecurityFilterChain that specifies JWT validation. The minimal configuration provides the public key for signature verification, either as a JWK set URI or an embedded key.
Spring Security handles JWT parsing, signature validation, and claim extraction automatically. The issuer and audience claims are validated against your configuration. The resulting Authentication object contains the JWT subject as the principal and the token scopes or claims as authorities.
For production, use a JWK Set endpoint (JWKS) from your authorization server so keys rotate without redeploying your service. If the authorization server signs tokens with RS256, your resource server uses the corresponding public key to verify signatures.
AuthenticationConverter extracts credentials from the incoming HTTP request and produces an Authentication token (such as UsernamePasswordAuthenticationToken). It is a function from HttpServletRequest to Authentication. Spring Security has converters for Basic auth, form login, and other mechanisms.
AuthenticationProvider takes an Authentication object that already has credentials (from a converter) and performs the actual authentication logic, returning an authenticated Authentication or throwing an AuthenticationException. Multiple providers can chain together to support different authentication mechanisms.
The flow is: Request comes in to AuthenticationConverter produces an unauthenticated token to AuthenticationManager tries providers one provider succeeds to authenticated token goes into SecurityContext. You implement AuthenticationProvider when you have custom authentication logic, and you might implement AuthenticationConverter when you need to read credentials from a non-standard location.
BCrypt stores the salt as part of the encoded password. When a user logs in, BCrypt encodes the provided password with the stored salt and compares the result to the stored hash. If they match, authentication succeeds.
Password migration happens when a user has old passwords stored in a weaker format. The pattern is: during authentication, detect the legacy format, migrate it to BCrypt by re-encoding with BCryptPasswordEncoder, and update the stored password. On the next login, BCrypt handles it normally.
Spring Security does not do this automatically. You implement it in your AuthenticationProvider or UserDetailsService by checking which encoder was used, migrating if needed, and updating the user record with the new hash. Always migrate during successful authentication, not on every request.
The LogoutFilter intercepts requests to the logout URL (default is /logout). When triggered, it performs these steps in order: clears the SecurityContext, invalidates the HTTP session, expires the session cookie, and redirects to a configurable logout success URL.
You can customize this with additional handlers for logout success scenarios. For example, you might want to call a revocation endpoint for OAuth tokens, write an audit log entry, or notify another service that the user logged out.
The key thing is that logout is not automatic. A user must send a request to the logout URL (typically a POST form submission). Spring Security does not expose a logout endpoint by default in the way you might expect from other frameworks.
The flow starts when a user clicks "Login with Provider". Your application redirects to the authorization server with a client ID, redirect URI, and requested scopes. The user authenticates with the provider and grants consent. The authorization server redirects back with a short-lived authorization code.
Your application receives the code and exchanges it (server-side, not in the browser) with the authorization server along with the client secret. The authorization server returns access and refresh tokens. Your application creates a local session and stores the tokens.
Spring Security OAuth 2.0 Client handles all of this automatically. You configure the client registration with the provider details, and the framework manages the redirects, code exchange, token storage, and user provisioning. You provide a OAuth2UserService to map the provider's user info to your application's user representation.
@Secured is the older annotation that only supports simple role names. You specify roles directly: @Secured({"ROLE_USER", "ROLE_ADMIN"}). It does not support SpEL expressions and throws an AccessDeniedException when access is denied.
@PreAuthorize (from @EnableMethodSecurity) supports SpEL expressions, so you can write conditions like @PreAuthorize("hasRole('USER') and #account.owner == authentication.name"). This lets you inspect method arguments and make context-aware decisions.
@PreAuthorize also supports @PostAuthorize which checks the returned value after the method executes. This is useful when you only know whether access is allowed after seeing the result, such as checking if a returned object belongs to the current user.
Spring Data integrates through the @Query annotation and method name conventions that generate queries. Spring Security does not have a built-in Spring Data integration, but you can combine them by using @Query with SpEL expressions that reference the authentication context.
For method-level security on repositories, you enable @EnableMethodSecurity and apply annotations to repository interface methods. Spring Data uses these annotations as part of its proxy-based method interception.
The practical pattern is to keep repository methods simple (just data access) and enforce security at the service layer with @PreAuthorize. This keeps your security logic centralized and easier to audit than scattering checks across many repository methods.
A forward() keeps the original request URL and passes it server-side to another resource. The browser URL does not change. This matters for Spring Security because the security filter chain processes the original URL, so path-based security rules apply to the target resource.
A redirect() sends a 302 or 303 response to the browser, which then makes a new request to the target URL. The browser URL changes, and Spring Security re-evaluates the security rules on the new URL. This can trigger the security filter chain again for the redirect target.
For authentication success handlers, redirect() is usually the right choice because it gives you a clean break and a new URL. For access denied scenarios, a forward() can sometimes hide the fact that the user tried to access something they should not have, which may or may not be desirable depending on your security logging requirements.
Remember-me authentication lets the server authenticate a returning user automatically based on a cookie, without requiring the user to enter credentials on every visit. Spring Security supports two strategies: hashing-based and token-based.
The hashing strategy (also called "remember-me" without specifying a service) uses a cookie value that is computed as username:expirationTime:md5(username:expirationTime:password). If any of these values change, the cookie becomes invalid. The token strategy (the default now) uses a series identifier and a random token, stored in the database.
Security trade-offs: remember-me tokens are long-lived and sent with every request, so they are exposed to network attackers. They should always be transmitted over HTTPS. Additionally, if an attacker obtains a remember-me token, they can impersonate the user for as long as the token is valid. Consider whether the convenience justifies the extended attack window for your application.
Stateful sessions use the servlet container's HttpSession to store security context. Spring Security stores the authentication in the session, and each subsequent request sends the session cookie (JSESSIONID). The server tracks who you are through the session object.
Stateless sessions do not use server-side session storage at all. The authentication information travels with each request, typically as a JWT in the Authorization header. Spring Security still uses SecurityContextHolder and ThreadLocal, but nothing persists between requests.
For microservices, stateless is usually preferred because any service instance can validate a token without a shared session store. For traditional web applications where session affinity (sticky sessions) is acceptable, stateful is simpler and revocation is immediate. The choice affects scaling, latency, and your ability to revoke access.
Further Reading
Dive deeper into the patterns and adjacent topics covered here.
OAuth 2.0 and OIDC
- OAuth 2.0 Deep Dive — Covers grant types, token flows, and OpenID Connect identity layer
- Spring Security OAuth 2.0 Resource Server — Official documentation for JWT validation with Spring Security
- OAuth 2.0 Security Best Current Practice — IETF guidance on current recommended practices
Method Security and AOP
- Spring Security Method Security Documentation — SpEL expressions in @PreAuthorize and @PostAuthorize
- Understand Spring AOP — How method interception works under the hood
Security Headers and Hardening
- Security Headers Guide — Configuring HSTS, CSP, X-Frame-Options, and other headers
- OWASP Top 10 — The most critical security risks to web applications
Testing Security
- Spring Boot Testing Security — Integration testing strategies for Spring Security configuration
- Spring Security Test Documentation — Mock users, CSRF testing, and filter chain testing
Architecture Patterns
- OAuth2 Resource Server — JWT validation and stateless service authentication
- Mutual TLS — Service-to-service authentication with client certificates
Conclusion
Spring Security rewards the time you invest in understanding it. The filter chain architecture, while initially confusing, becomes intuitive once you trace a single request through it. Authentication and authorization are separate concerns that build on each other. Method-level security adds a defense-in-depth layer that catches issues controllers miss.
For your next project, start with the basics: a SecurityConfig, a UserDetailsService backed by your actual user store, and BCrypt for passwords. Add OAuth only when you need it. Turn on method security for sensitive service operations. Wire up the event listener for audit trails. You can evolve from there.
Category
Related Posts
Spring Boot Build Tools: Maven & Gradle
Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.
Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow
Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.
JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests
Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.