- Overview
- Security Concepts
- Setup & Configuration
- User Management
- Role-Based Access Control
- Database Integration
- Password Encryption
- Custom Tables
- Complete Examples
This guide demonstrates how to secure Spring Boot REST APIs using Spring Security with progressive complexity levels:
- In-memory user storage
- Database storage with plain-text passwords
- Database storage with encrypted passwords (bcrypt)
- Custom database schemas
- Secure REST API endpoints
- Define users and roles
- Protect URLs based on roles
- Store credentials in database (plain-text → encrypted)
- Use custom database schemas
Verifies user identity by checking credentials (username/password) against stored values.
Determines if authenticated user has permission to access specific resources based on their role.
Spring Security uses Servlet Filters to:
- Pre-process/post-process web requests
- Route requests based on security logic
- Validate credentials and roles
Add to pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>Result: All endpoints are automatically secured.
In src/main/resources/application.properties:
spring.security.user.name=scott
spring.security.user.password=test123Default behavior:
- Username:
user - Password: Check console logs for auto-generated password
Create security configuration class:
File: DemoSecurityConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class DemoSecurityConfig {
@Bean
public InMemoryUserDetailsManager userDetailsManager() {
UserDetails john = User.builder()
.username("john")
.password("{noop}test123")
.roles("EMPLOYEE")
.build();
UserDetails mary = User.builder()
.username("mary")
.password("{noop}test123")
.roles("EMPLOYEE", "MANAGER")
.build();
UserDetails susan = User.builder()
.username("susan")
.password("{noop}test123")
.roles("EMPLOYEE", "MANAGER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(john, mary, susan);
}
}{id}encodedPassword
Common IDs:
{noop}- Plain text passwords{bcrypt}- BCrypt encrypted passwords
Example: {noop}test123
File: src/main/java/com/example/config/SecurityConfig.java
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public InMemoryUserDetailsManager userDetailsManager() {
UserDetails employee = User.builder()
.username("employee")
.password("{noop}emp123")
.roles("EMPLOYEE")
.build();
UserDetails manager = User.builder()
.username("manager")
.password("{noop}mgr123")
.roles("EMPLOYEE", "MANAGER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{noop}adm123")
.roles("EMPLOYEE", "MANAGER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(employee, manager, admin);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Disable for stateless APIs
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/employees").hasRole("EMPLOYEE")
.requestMatchers("/api/employees/{id}").hasRole("EMPLOYEE")
.requestMatchers("/api/employees").hasRole("MANAGER")
.requestMatchers("/api/employees/delete/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(basic -> {});
return http.build();
}
}File: src/main/java/com/example/controller/SecureEmployeeController.java
package com.example.controller;
import com.example.entity.Employee;
import com.example.service.EmployeeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/employees")
public class SecureEmployeeController {
@Autowired
private EmployeeService employeeService;
// Public endpoint - no authentication required
@GetMapping("/public/count")
public ResponseEntity<Long> getEmployeeCount() {
return ResponseEntity.ok(employeeService.getTotalEmployees());
}
// EMPLOYEE role required
@GetMapping
@PreAuthorize("hasRole('EMPLOYEE')")
public ResponseEntity<List<Employee>> getAllEmployees(Authentication auth) {
System.out.println("User: " + auth.getName() + " accessed employee list");
return ResponseEntity.ok(employeeService.getAllEmployees());
}
// EMPLOYEE role required
@GetMapping("/{id}")
@PreAuthorize("hasRole('EMPLOYEE')")
public ResponseEntity<Employee> getEmployeeById(@PathVariable Integer id) {
Employee employee = employeeService.getEmployeeById(id);
return ResponseEntity.ok(employee);
}
// MANAGER role required
@PostMapping
@PreAuthorize("hasRole('MANAGER')")
public ResponseEntity<Employee> createEmployee(@RequestBody Employee employee) {
Employee createdEmployee = employeeService.createEmployee(employee);
return ResponseEntity.status(HttpStatus.CREATED).body(createdEmployee);
}
// MANAGER role required
@PutMapping("/{id}")
@PreAuthorize("hasRole('MANAGER')")
public ResponseEntity<Employee> updateEmployee(
@PathVariable Integer id,
@RequestBody Employee employeeDetails) {
Employee updatedEmployee = employeeService.updateEmployee(id, employeeDetails);
return ResponseEntity.ok(updatedEmployee);
}
// ADMIN role required
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteEmployee(@PathVariable Integer id) {
employeeService.deleteEmployee(id);
return ResponseEntity.noContent().build();
}
// Get current user info
@GetMapping("/current-user")
public ResponseEntity<String> getCurrentUser(Authentication auth) {
return ResponseEntity.ok("Current user: " + auth.getName() +
" with roles: " + auth.getAuthorities());
}
}File: src/test/java/com/example/controller/SecurityTest.java
package com.example.controller;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
@SpringBootTest
@AutoConfigureMockMvc
public class SecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testUnauthenticatedAccessDenied() throws Exception {
mockMvc.perform(get("/api/employees"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "employee", roles = {"EMPLOYEE"})
public void testEmployeeCanViewEmployees() throws Exception {
mockMvc.perform(get("/api/employees"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "manager", roles = {"MANAGER"})
public void testManagerCanCreateEmployee() throws Exception {
String employeeJson = "{\"firstName\":\"John\",\"lastName\":\"Doe\",\"email\":\"john@example.com\"}";
mockMvc.perform(post("/api/employees")
.with(csrf())
.contentType("application/json")
.content(employeeJson))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(username = "employee", roles = {"EMPLOYEE"})
public void testEmployeeCannotCreateEmployee() throws Exception {
String employeeJson = "{\"firstName\":\"John\",\"lastName\":\"Doe\",\"email\":\"john@example.com\"}";
mockMvc.perform(post("/api/employees")
.with(csrf())
.contentType("application/json")
.content(employeeJson))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
public void testAdminCanDeleteEmployee() throws Exception {
mockMvc.perform(delete("/api/employees/1")
.with(csrf()))
.andExpect(status().isNoContent());
}
}# GET request with basic auth (employee role)
curl -u employee:emp123 http://localhost:8080/api/employees
# GET request with basic auth (manager role)
curl -u manager:mgr123 http://localhost:8080/api/employees
# Try unauthorized access (will fail)
curl http://localhost:8080/api/employees
# Create employee as manager
curl -u manager:mgr123 \
-X POST http://localhost:8080/api/employees \
-H "Content-Type: application/json" \
-d '{
"firstName":"Jane",
"lastName":"Smith",
"email":"jane@example.com",
"department":"Marketing",
"salary":70000
}'
# Try to create as employee (will fail with 403)
curl -u employee:emp123 \
-X POST http://localhost:8080/api/employees \
-H "Content-Type: application/json" \
-d '{
"firstName":"Bob",
"lastName":"Wilson",
"email":"bob@example.com",
"department":"IT",
"salary":75000
}'
# Delete employee as admin
curl -u admin:adm123 -X DELETE http://localhost:8080/api/employees/1| HTTP Method | Endpoint | Action | Required Role |
|---|---|---|---|
| GET | /api/employees | Read all | EMPLOYEE |
| GET | /api/employees/{id} | Read single | EMPLOYEE |
| POST | /api/employees | Create | MANAGER |
| PUT | /api/employees | Update | MANAGER |
| PATCH | /api/employees/{id} | Partial Update | MANAGER |
| DELETE | /api/employees/{id} | Delete | ADMIN |
File: DemoSecurityConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class DemoSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(configurer ->
configurer
// GET requests - EMPLOYEE role
.requestMatchers(HttpMethod.GET, "/api/employees").hasRole("EMPLOYEE")
.requestMatchers(HttpMethod.GET, "/api/employees/**").hasRole("EMPLOYEE")
// POST requests - MANAGER role
.requestMatchers(HttpMethod.POST, "/api/employees").hasRole("MANAGER")
// PUT requests - MANAGER role
.requestMatchers(HttpMethod.PUT, "/api/employees/**").hasRole("MANAGER")
// PATCH requests - MANAGER role
.requestMatchers(HttpMethod.PATCH, "/api/employees/**").hasRole("MANAGER")
// DELETE requests - ADMIN role
.requestMatchers(HttpMethod.DELETE, "/api/employees/**").hasRole("ADMIN")
);
// Use HTTP Basic authentication
http.httpBasic(Customizer.withDefaults());
// Disable CSRF (not needed for stateless REST APIs)
http.csrf(csrf -> csrf.disable());
return http.build();
}
}Single role:
.requestMatchers(HttpMethod.GET, "/api/employees").hasRole("EMPLOYEE")Multiple roles:
.requestMatchers(HttpMethod.POST, "/api/employees").hasAnyRole("MANAGER", "ADMIN")Wildcard paths (** matches all sub-paths):
.requestMatchers(HttpMethod.GET, "/api/employees/**").hasRole("EMPLOYEE")When to disable CSRF:
- REST APIs for non-browser clients
- Stateless APIs using POST, PUT, DELETE, PATCH
When to enable CSRF:
- Traditional web apps with HTML forms
- Browser-based form submissions
// Disable CSRF for REST APIs
http.csrf(csrf -> csrf.disable());When using Spring Data REST with PUT requests, the ID is passed in the URL:
PUT /api/employees/{employeeId}
Use this configuration:
.requestMatchers(HttpMethod.PUT, "/api/employees/**").hasRole("MANAGER")Spring Security provides predefined table schemas for user authentication.
File: create-tables.sql
Users Table:
CREATE TABLE `users` (
`username` varchar(50) NOT NULL,
`password` varchar(50) NOT NULL,
`enabled` tinyint NOT NULL,
PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;Insert Users (plain-text passwords):
INSERT INTO `users`
VALUES
('john', '{noop}test123', 1),
('mary', '{noop}test123', 1),
('susan', '{noop}test123', 1);Authorities Table (Roles):
CREATE TABLE `authorities` (
`username` varchar(50) NOT NULL,
`authority` varchar(50) NOT NULL,
UNIQUE KEY `authorities_idx_1` (`username`, `authority`),
CONSTRAINT `authorities_ibfk_1`
FOREIGN KEY (`username`)
REFERENCES `users` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;Insert Authorities:
INSERT INTO `authorities`
VALUES
('john', 'ROLE_EMPLOYEE'),
('mary', 'ROLE_EMPLOYEE'),
('mary', 'ROLE_MANAGER'),
('susan', 'ROLE_EMPLOYEE'),
('susan', 'ROLE_MANAGER'),
('susan', 'ROLE_ADMIN');Note: Spring Security uses ROLE_ prefix internally for authorities.
File: pom.xml
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>File: src/main/resources/application.properties
# JDBC connection properties
spring.datasource.url=jdbc:mysql://localhost:3306/employee_directory
spring.datasource.username=springstudent
spring.datasource.password=springstudentFile: DemoSecurityConfig.java
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
@Configuration
public class DemoSecurityConfig {
@Bean
public UserDetailsManager userDetailsManager(DataSource dataSource) {
// DataSource is auto-configured by Spring Boot
return new JdbcUserDetailsManager(dataSource);
}
// ... SecurityFilterChain bean remains the same
}Key Points:
DataSourceis auto-configured by Spring Boot- No more hard-coded users!
- Spring Security automatically queries the database
Why BCrypt?
- One-way encrypted hashing
- Adds random salt for additional protection
- Defeats brute force attacks
- Recommended by Spring Security team
{bcrypt}$2a$10$qeS0HEh7urweMojsnwNAR.vcXJeXR1UcMRZ2WcGQl9YeuspUdgF.q
Structure:
{bcrypt}- 8 characters (encoding algorithm ID)- Encrypted password - 60 characters
- Total: Minimum 68 characters
Option 1: Online Tool Visit: BCrypt Password Generator
Option 2: Java Code
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class PasswordGenerator {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String password = "fun123";
String encodedPassword = encoder.encode(password);
System.out.println("Encoded: " + encodedPassword);
}
}CREATE TABLE `users` (
`username` varchar(50) NOT NULL,
`password` char(68) NOT NULL, -- Changed to 68 characters
`enabled` tinyint NOT NULL,
PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;INSERT INTO `users`
VALUES
('john', '{bcrypt}$2a$10$qeS0HEh7urweMojsnwNAR.vcXJeXR1UcMRZ2WcGQl9YeuspUdgF.q', 1),
('mary', '{bcrypt}$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K', 1),
('susan', '{bcrypt}$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K', 1);No Java Code Changes Required! Spring Security automatically:
- Reads the encoding algorithm ID (
{bcrypt}) - Encrypts the plaintext password from login form
- Compares encrypted passwords
- Never decrypts the database password
- User enters plaintext password
- Spring Security retrieves encrypted password from database
- Reads encoding algorithm (
{bcrypt}) - Encrypts plaintext password using salt from DB password
- Compares encrypted passwords
- Login succeeds if passwords match
Important: Database password is NEVER decrypted (one-way encryption).
What if your database doesn't match Spring Security's default schema?
Example Custom Schema:
Members Table:
CREATE TABLE `members` (
`user_id` varchar(50) NOT NULL,
`pw` char(68) NOT NULL,
`active` tinyint NOT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;Roles Table:
CREATE TABLE `roles` (
`user_id` varchar(50) NOT NULL,
`role` varchar(50) NOT NULL,
UNIQUE KEY `authorities_idx_1` (`user_id`, `role`),
CONSTRAINT `authorities_ibfk_1`
FOREIGN KEY (`user_id`)
REFERENCES `members` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;Insert Sample Data:
INSERT INTO `members`
VALUES
('john', '{bcrypt}$2a$10$qeS0HEh7urweMojsnwNAR.vcXJeXR1UcMRZ2WcGQl9YeuspUdgF.q', 1),
('mary', '{bcrypt}$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K', 1),
('susan', '{bcrypt}$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K', 1);
INSERT INTO `roles`
VALUES
('john', 'ROLE_EMPLOYEE'),
('mary', 'ROLE_EMPLOYEE'),
('mary', 'ROLE_MANAGER'),
('susan', 'ROLE_EMPLOYEE'),
('susan', 'ROLE_MANAGER'),
('susan', 'ROLE_ADMIN');File: DemoSecurityConfig.java
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
@Configuration
public class DemoSecurityConfig {
@Bean
public UserDetailsManager userDetailsManager(DataSource dataSource) {
JdbcUserDetailsManager theUserDetailsManager = new JdbcUserDetailsManager(dataSource);
// Query to find user by username
// ? parameter will be the username from login
theUserDetailsManager.setUsersByUsernameQuery(
"select user_id, pw, active from members where user_id=?"
);
// Query to find authorities/roles by username
// ? parameter will be the username from login
theUserDetailsManager.setAuthoritiesByUsernameQuery(
"select user_id, role from roles where user_id=?"
);
return theUserDetailsManager;
}
// ... SecurityFilterChain bean remains the same
}Query Requirements:
- User query must return:
username,password,enabled - Authority query must return:
username,authority ?is replaced with the username from login form
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class DemoSecurityConfig {
@Bean
public InMemoryUserDetailsManager userDetailsManager() {
UserDetails john = User.builder()
.username("john")
.password("{noop}test123")
.roles("EMPLOYEE")
.build();
UserDetails mary = User.builder()
.username("mary")
.password("{noop}test123")
.roles("EMPLOYEE", "MANAGER")
.build();
UserDetails susan = User.builder()
.username("susan")
.password("{noop}test123")
.roles("EMPLOYEE", "MANAGER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(john, mary, susan);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(configurer ->
configurer
.requestMatchers(HttpMethod.GET, "/api/employees").hasRole("EMPLOYEE")
.requestMatchers(HttpMethod.GET, "/api/employees/**").hasRole("EMPLOYEE")
.requestMatchers(HttpMethod.POST, "/api/employees").hasRole("MANAGER")
.requestMatchers(HttpMethod.PUT, "/api/employees/**").hasRole("MANAGER")
.requestMatchers(HttpMethod.PATCH, "/api/employees/**").hasRole("MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/employees/**").hasRole("ADMIN")
);
http.httpBasic(Customizer.withDefaults());
http.csrf(csrf -> csrf.disable());
return http.build();
}
}import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class DemoSecurityConfig {
@Bean
public UserDetailsManager userDetailsManager(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(configurer ->
configurer
.requestMatchers(HttpMethod.GET, "/api/employees").hasRole("EMPLOYEE")
.requestMatchers(HttpMethod.GET, "/api/employees/**").hasRole("EMPLOYEE")
.requestMatchers(HttpMethod.POST, "/api/employees").hasRole("MANAGER")
.requestMatchers(HttpMethod.PUT, "/api/employees/**").hasRole("MANAGER")
.requestMatchers(HttpMethod.PATCH, "/api/employees/**").hasRole("MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/employees/**").hasRole("ADMIN")
);
http.httpBasic(Customizer.withDefaults());
http.csrf(csrf -> csrf.disable());
return http.build();
}
}application.properties:
spring.datasource.url=jdbc:mysql://localhost:3306/employee_directory
spring.datasource.username=springstudent
spring.datasource.password=springstudentimport javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class DemoSecurityConfig {
@Bean
public UserDetailsManager userDetailsManager(DataSource dataSource) {
JdbcUserDetailsManager theUserDetailsManager = new JdbcUserDetailsManager(dataSource);
theUserDetailsManager.setUsersByUsernameQuery(
"select user_id, pw, active from members where user_id=?"
);
theUserDetailsManager.setAuthoritiesByUsernameQuery(
"select user_id, role from roles where user_id=?"
);
return theUserDetailsManager;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(configurer ->
configurer
.requestMatchers(HttpMethod.GET, "/api/employees").hasRole("EMPLOYEE")
.requestMatchers(HttpMethod.GET, "/api/employees/**").hasRole("EMPLOYEE")
.requestMatchers(HttpMethod.POST, "/api/employees").hasRole("MANAGER")
.requestMatchers(HttpMethod.PUT, "/api/employees/**").hasRole("MANAGER")
.requestMatchers(HttpMethod.PATCH, "/api/employees/**").hasRole("MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/employees/**").hasRole("ADMIN")
);
http.httpBasic(Customizer.withDefaults());
http.csrf(csrf -> csrf.disable());
return http.build();
}
}# Read all employees (Success - 200 OK)
curl -u john:test123 http://localhost:8080/api/employees
# Read single employee (Success - 200 OK)
curl -u john:test123 http://localhost:8080/api/employees/1
# Try to create employee (Fail - 403 Forbidden)
curl -u john:test123 -X POST http://localhost:8080/api/employees \
-H "Content-Type: application/json" \
-d '{"firstName":"David","lastName":"Brown","email":"david@example.com"}'# Read employees (Success - 200 OK)
curl -u mary:test123 http://localhost:8080/api/employees
# Create employee (Success - 201 Created)
curl -u mary:test123 -X POST http://localhost:8080/api/employees \
-H "Content-Type: application/json" \
-d '{"firstName":"David","lastName":"Brown","email":"david@example.com"}'
# Update employee (Success - 200 OK)
curl -u mary:test123 -X PUT http://localhost:8080/api/employees/1 \
-H "Content-Type: application/json" \
-d '{"firstName":"John","lastName":"Updated","email":"john@example.com"}'
# Partial update (Success - 200 OK)
curl -u mary:test123 -X PATCH http://localhost:8080/api/employees/1 \
-H "Content-Type: application/json" \
-d '{"email":"newemail@example.com"}'
# Try to delete (Fail - 403 Forbidden)
curl -u mary:test123 -X DELETE http://localhost:8080/api/employees/1# All operations allowed
# Delete employee (Success - 200 OK)
curl -u susan:test123 -X DELETE http://localhost:8080/api/employees/1- Add
spring-boot-starter-securitydependency - Create
DemoSecurityConfigclass with@Configuration - Define users with
InMemoryUserDetailsManager - Configure
SecurityFilterChainwith role-based access - Disable CSRF for REST APIs
- Add MySQL dependency
- Create
usersandauthoritiestables - Configure
application.propertieswith database connection - Update config to use
JdbcUserDetailsManager - Test with plain-text passwords
- Generate BCrypt passwords
- Modify
passwordcolumn tochar(68) - Update INSERT statements with
{bcrypt}prefix - Test login with encrypted passwords
- Create custom database schema
- Configure custom SQL queries in
DemoSecurityConfig - Test authentication with custom tables
- Spring Security Reference: Official Documentation
- BCrypt Generator: Generate BCrypt Passwords
- BCrypt Algorithm: Why Use BCrypt
- Password Best Practices: Hashing Guidelines
JWT is a stateless authentication method perfect for REST APIs:
- Client sends credentials
- Server returns JWT token
- Client includes token in subsequent requests
- Server validates token without storing session
Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Step 1: Add Dependency
<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>Step 2: JWT Provider
@Component
public class JwtTokenProvider {
@Value("${app.jwtSecret:mySecretKeyThatMustBeLongerThan32Characters}")
private String jwtSecret;
@Value("${app.jwtExpirationInMs:86400000}") // 24 hours
private long jwtExpirationInMs;
public String generateToken(String username) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
return Jwts.builder()
.subject(username)
.issuedAt(now)
.expiration(expiryDate)
.signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes()), Jwts.SIG.HS512)
.compact();
}
public String getUsernameFromJwt(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes()))
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes()))
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}Step 3: JWT Authentication Filter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = extractTokenFromRequest(request);
if (token != null && tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsernameFromJwt(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String extractTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}CSRF (Cross-Site Request Forgery) - attacker tricks user into performing unwanted action.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Stateless APIs don't need CSRF
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.httpBasic(basic -> {})
.addFilterBefore(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}For Thymeleaf forms, include CSRF token:
<form method="POST" action="/api/employees">
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>
<input type="text" name="firstName" />
<button type="submit">Save</button>
</form># Enforce HTTPS in production
server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=${SSL_PASSWORD}
server.ssl.key-store-type=PKCS12// ✅ CORRECT - Use BCryptPasswordEncoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// ❌ WRONG - Storing plain text
password = "myPassword" // NEVER!@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.contentSecurityPolicy(csp ->
csp.policyDirectives("default-src 'self'"))
.xssProtection(xss -> xss.enable())
.frameOptions(frame -> frame.deny()))
.build();
return http.build();
}
}@Component
public class RateLimitingFilter extends OncePerRequestFilter {
private final Map<String, List<Long>> requestCounts = new ConcurrentHashMap<>();
private static final int MAX_REQUESTS = 100;
private static final long TIME_WINDOW = 60000; // 1 minute
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String clientIp = request.getRemoteAddr();
List<Long> requests = requestCounts.computeIfAbsent(clientIp, k -> new ArrayList<>());
long now = System.currentTimeMillis();
requests.removeIf(timestamp -> now - timestamp > TIME_WINDOW);
if (requests.size() >= MAX_REQUESTS) {
response.sendError(HttpServletResponse.SC_TOO_MANY_REQUESTS,
"Rate limit exceeded");
return;
}
requests.add(now);
filterChain.doFilter(request, response);
}
}@Component
public class SecurityAuditListener {
private static final Logger logger = LoggerFactory.getLogger(SecurityAuditListener.class);
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
logger.info("User {} authenticated successfully",
event.getAuthentication().getName());
}
@EventListener
public void onAuthenticationFailure(AuthenticationFailureBadCredentialsEvent event) {
logger.warn("Failed login attempt for user: {}",
event.getAuthentication().getName());
}
}Problem: Password appears in logs during authentication failure
DEBUG - Attempting authentication for user: john with password: secret123
Solution: Use logging filters and sanitization
@Component
public class SensitiveDataFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if (request.getParameter("password") != null) {
// Don't log passwords
logger.debug("Password field detected - will not log");
}
filterChain.doFilter(request, response);
}
}Problem: Expired tokens still accepted
Solution: Add token refresh mechanism
@PostMapping("/api/auth/refresh")
public ResponseEntity<String> refreshToken(@RequestHeader("Authorization") String token) {
if (tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsernameFromJwt(token);
String newToken = tokenProvider.generateToken(username);
return ResponseEntity.ok(newToken);
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}Problem: Stack traces and database details exposed to users
ERROR - org.hibernate.exception.ConstraintViolationException:
Duplicate entry 'user@example.com' for key 'users.email_unique'
Solution: Return generic error messages
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(
DataIntegrityViolationException e) {
logger.error("Data integrity violation", e);
ErrorResponse error = new ErrorResponse("Validation failed",
"Please check your input and try again");
return ResponseEntity.badRequest().body(error);
}
}✅ Authentication
├── Test valid credentials
├── Test invalid credentials
├── Test missing credentials
└── Test token expiration
✅ Authorization
├── Test role-based access
├── Test permission checks
├── Test cross-user access attempts
└── Test privilege escalation
✅ API Security
├── Test CSRF protection (if applicable)
├── Test SQL injection prevention
├── Test XSS prevention
└── Test rate limiting
✅ Data Security
├── Test password hashing
├── Test HTTPS enforcement
├── Test sensitive data logging
└── Test data exposure in responses
✅ Session Management
├── Test session timeout
├── Test concurrent sessions
├── Test logout functionality
└── Test session fixation prevention
- Password Column Length: Must be at least 68 characters for BCrypt
- ROLE_ Prefix: Spring Security adds this automatically, but must be in database
- CSRF: Disable for stateless REST APIs
- Wildcard Paths: Use
/**for matching all sub-paths - Spring Data REST: Use
/**with PUT requests when ID is in URL - No Decryption: BCrypt is one-way; passwords are never decrypted
- JWT Secret: Keep secret key secure and at least 256-bit long
- HTTPS: Always use HTTPS in production for token transmission
- Token Storage: Store tokens securely on client side (HttpOnly cookies or secure storage)
- Token Rotation: Implement token refresh for long-lived sessions
Happy Coding! 🚀