A hands-on, step-by-step guide to understanding and implementing Spring Security , from zero configuration to full JWT-based stateless authentication.
- Core Principles
- Authentication Flow
- Setting Up the Project
- Adding the Spring Security Dependency
- Basic Authentication & Writing a Security Filter Chain
- Stateless Authentication (Removing Session Cookies)
- In-Memory Authentication
- Role-Based Authorization
- Moving Credentials to a Database (H2)
- Database Authentication with JdbcUserDetailsManager
- Password Encoding with BCrypt
- JWT Authentication
- Quick Reference / Cheat Sheet
Before touching any code, keep these security principles in mind , they apply to every decision you make while securing an application:
| Principle | What it means in practice |
|---|---|
| Least privilege | Give each user/role only the access they need, nothing more. |
| Secure by design | Security isn't bolted on at the end, it's part of the architecture from day one. |
| Fail-safe defaults | If something goes wrong, the system should deny access by default, not allow it. |
| Secure communication | Use HTTPS/TLS; never send credentials or tokens over plain HTTP. |
| Input validation | Never trust client input, validate everything on the server. |
| Auditing & logging | Log authentication attempts and security-relevant events for traceability. |
| Regular updates & patching | Keep dependencies (especially security libraries) up to date. |
Understanding how Spring Security processes a request is the single most useful mental model you can build. Here's the flow, end end:
Spring Security Authentication Flow Diagram, Security Filter Chain → AuthenticationManager → AuthenticationProvider → UserDetailsService → PasswordEncoder → SecurityContext
Step by step:
- Every incoming request passes through a chain of security filters (the Security Filter Chain).
- When a login request arrives, the authentication filter extracts the username/password and wraps them in an
Authenticationobject (typically aUsernamePasswordAuthenticationToken). - This
Authenticationobject is handed to theAuthenticationManager: the central API that defines how authentication is performed. In practice, this is almost always aProviderManager. - The
ProviderManagerdelegates the actual work to anAuthenticationProvider(e.g.DaoAuthenticationProvider), which uses:
PasswordEncoder: encodes/verifies the submitted password.UserDetailsService: loads the user's data (username, password hash, roles) from wherever it's stored (memory, database, etc.).
- If credentials match, a fully populated
UserDetailsobject is returned : containing the user's roles/permissions and account status flags (isEnabled,isAccountNonExpired, etc.). - The resulting
Authenticationobject is stored in theSecurityContext(held bySecurityContextHolder) for the duration of the request. - The request is now allowed to proceed to the controller.
Key interfaces to remember:
UserDetails,UserDetailsService,AuthenticationManager,AuthenticationProvider,PasswordEncoder.
Start with a minimal REST controller to have something to test security against:
@RestController
public class GreetingsController {
@GetMapping("/hello")
public String sayHello() {
return "Hello World";
}
}💡 Tip: Run the Spring Boot app after every change described below so you can see exactly what each configuration line does.
Add the Spring Boot Security starter to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>What happens immediately after adding this:
- Spring Security auto-secures every endpoint by default.
- It auto-generates a default login page.
- On startup, it prints a random generated password to the console , this is your login password until you configure your own.
To set a fixed username/password instead, add this to application.properties:
spring.security.user.name=aysha
spring.security.user.password=aysha@testRun and visit: localhost:8080/hello , you'll be redirected to a login form.
To understand what Spring Security configures for you by default, look at ServletWebSecurityAutoConfiguration (in IntelliJ: double-Shift → search the class name).
It reveals the default filter chain bean, roughly equivalent to:
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(requests -> requests.anyRequest().authenticated());
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
}This is your starting template. Copy it into your own SecurityConfig.java so you can customize it:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(requests ->
requests.anyRequest().authenticated()); // every request must be authenticated
// http.formLogin(withDefaults()); // disabled below
http.httpBasic(withDefaults()); // use HTTP Basic auth instead
return http.build();
}
}What changes when you comment out formLogin:
- The default login page disappears.
- Instead, the browser shows a native Basic Auth popup (username/password dialog).
Run and test: localhost:8080/hello
By default, once you log in, Spring Security creates a session and issues a JSESSIONID cookie so you stay logged in across requests. For REST APIs (especially ones meant to work with JWTs later), you usually want no sessions at all , every request must authenticate itself independently.
Disable session creation like this:
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));Full config at this stage:
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(requests ->
requests.anyRequest().authenticated());
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
}Result: No cookies are issued. You can verify this in the browser's DevTools → Network → Cookies tab, or in Postman's Cookies panel.

Before wiring up a real database, it's useful to define users directly in code. This is done with InMemoryUserDetailsManager, which implements UserDetailsManager (which in turn extends UserDetailsService).
@Bean
public UserDetailsService userDetailsService() {
UserDetails user1 = User.withUsername("user1")
.password("{noop}aysha@test")
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password("{noop}admin@test")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user1, admin);
}
⚠️ {noop}is a placeholder password encoder that means "no encoding , store as plain text." It's fine for learning/testing, but never use it in a real application. We'll replace it withBCryptPasswordEncoderin Section 11.
Run and test with either the browser's Basic Auth popup or Postman:
Once users have roles, you can restrict individual endpoints using @PreAuthorize.
@PreAuthorize("hasRole('USER')")
@GetMapping("/user")
public String userEndpoint() {
return "Hello User :)";
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin")
public String adminEndpoint() {
return "Hello Admin :)";
}Method-level security (@PreAuthorize) is not enabled by default , you need to turn it on explicitly:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// ...
}Result: if a USER tries to hit /admin (or vice-versa), they get a 403 Forbidden.
Hardcoding users is fine for a demo, but real apps need persistent user storage. H2 is a lightweight in-memory/file database perfect for learning this.
Dependencies (add via Spring Initializr or manually):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>application.properties:
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testYou can connect to this database directly from IntelliJ (via the Database icon), or through the built-in web console at:
localhost:8080/h2-console
By default, Spring Security blocks access to /h2-console, and because the H2 console runs inside an <iframe>, Spring Security's frame protection blocks it too. Fix all of this with:
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(requests ->
requests.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated());
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.httpBasic(withDefaults());
http.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")); // H2 console doesn't send CSRF tokens
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())); // allow the iframe
return http.build();
}Now /h2-console loads and logs in without issues.
Instead of storing users in a Java class, let Spring Security read/write them directly from your database using JdbcUserDetailsManager.
@Autowired
DataSource dataSource;
@Bean
public UserDetailsService userDetailsService() {
UserDetails user1 = User.withUsername("user1")
.password("{noop}aysha@test")
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password("{noop}admin@test")
.roles("ADMIN")
.build();
JdbcUserDetailsManager userDetailsManager = new JdbcUserDetailsManager(dataSource);
userDetailsManager.createUser(user1);
userDetailsManager.createUser(admin);
return userDetailsManager;
}Running this will throw an error the first time , JdbcUserDetailsManager expects specific tables (users, authorities) that don't exist yet.
Fix: create a schema.sql file in src/main/resources using Spring Security's official schema definition:
Reference:
users.ddlon the Spring Security GitHub repo
Copy that DDL into schema.sql, restart the app , the error is gone, and the USERS / AUTHORITIES tables now exist. Confirm this in the H2 console:
Test with Postman using the created credentials , you should get a 200 OK and the response body from your protected endpoint.
Storing plain-text passwords (even the {noop} placeholder) is not acceptable outside of learning exercises. Replace it with BCrypt, an industry-standard adaptive hashing algorithm.
1. Define a PasswordEncoder bean:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}2. Use it when creating users , remove {noop} and encode the password instead:
UserDetails user1 = User.withUsername("user1")
.password(passwordEncoder().encode("Aysha@test"))
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password(passwordEncoder().encode("admin@test"))
.roles("ADMIN")
.build();Result: passwords in the database are now stored as BCrypt hashes (e.g. $2a$10$VfVjATgIlXncFI2FR...), never in plain text.
Basic Auth is fine for learning, but real-world APIs almost always use JWT (JSON Web Tokens) for stateless authentication , no server-side session, no cookies, just a signed token the client sends with every request.
- Tokens carry an expiration time , no indefinite sessions.
- The client authenticates once and reuses the token for subsequent requests.
- The payload can be decoded (though not modified) by anyone , never put secrets in it.
- User submits login credentials via the client app.
- Server validates credentials and generates a JWT, signed with a secret key.
- Server returns the JWT to the client.
- The client includes this JWT (usually in the
Authorization: Bearer <token>header) on every subsequent request , no need to re-send username/password.
A JWT has three dot-separated parts: header.payload.signature
| Part | Contents | Example |
|---|---|---|
| Header | Algorithm & token type | { "alg": "RS256", "typ": "JWT" } |
| Payload | Claims : user data, expiry, roles | { "name": "...", "exp": 1703037180, "sub": "...", "admin": true } |
| Signature | Cryptographic signature ensuring the token hasn't been tampered with | (binary, not human-readable) |
⚠️ The payload is base64-encoded, not encrypted , anyone can decode and read it. Never store passwords or sensitive secrets in the payload.
1. Add the JJWT dependencies (check jwtk/jjwt on GitHub for the latest version):
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if you prefer Gson -->
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>2. Create a jwt package with these four classes:
| Class | Responsibility |
|---|---|
JwtUtils |
Generates, parses, and validates JWTs; extracts claims like the username. |
AuthTokenFilter |
A custom filter placed in the security chain that intercepts each request, extracts the token, and validates it. |
AuthEntryPointJwt |
Custom handler for unauthorized access attempts , logs the error and returns a clean JSON error response instead of a default HTML page. |
SecurityConfig |
Wires everything together , registers the JWT filter in the Spring Security filter chain. |
3. Configure the JWT secret & expiration in application.properties:
spring.app.jwtSecret=YourStrongSecretKeyHere!#12345
spring.app.jwtExpirationMs=86400000
⚠️ Use a strong, randomly generated secret in production , never commit it to source control. Consider loading it from an environment variable instead. Also use a realistic expiration (e.g.86400000ms = 24 hours) rather than an extremely long value.
Step 1 : Sign in and receive a token:
POST localhost:8080/signin
Content-Type: application/json
{
"username": "admin",
"password": "admin@test"
}Response:
{
"username": "admin",
"roles": ["ROLE_ADMIN"],
"jwtToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsIm..."
}Step 2 : Use the token on protected endpoints:
GET localhost:8080/admin
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsIm...| Concept | Class / Annotation |
|---|---|
| Central authentication logic | AuthenticationManager (usually ProviderManager) |
| Delegates auth to a provider | AuthenticationProvider (e.g. DaoAuthenticationProvider) |
| Loads user from a source | UserDetailsService |
| Users defined in code | InMemoryUserDetailsManager |
| Users stored in a database | JdbcUserDetailsManager |
| Encodes/verifies passwords | PasswordEncoder → use BCryptPasswordEncoder |
| Holds the current authenticated user | SecurityContextHolder / SecurityContext |
| Disables sessions (stateless) | SessionCreationPolicy.STATELESS |
| Restrict endpoint by role | @PreAuthorize("hasRole('...')") |
| Enable method-level security | @EnableMethodSecurity |
| Main security configuration | SecurityFilterChain bean inside a @Configuration class |
1. Get a REST endpoint running
2. Add spring-boot-starter-security → see default lockdown
3. Write your own SecurityFilterChain (Basic Auth)
4. Make it stateless (no sessions)
5. Define users in-memory
6. Add role-based restrictions (@PreAuthorize)
7. Move users to a real database (H2 + JdbcUserDetailsManager)
8. Encode passwords properly (BCrypt)
9. Replace Basic Auth with JWT for a production-style stateless API
Note: Spring Security's APIs evolve between major versions , always cross-check class/method names (e.g. SecurityFilterChain, lambda-based DSL) against the version you're using via the official Spring Security documentation.










