Spring Security — Role-Based Access Control (RBAC)
Implement role-based access control in Spring Boot 4 — roles vs authorities, @PreAuthorize, method-level security, hierarchical roles, and database-backed permissions.
Authentication tells you who someone is. Authorization tells you what they can do. The JWT authentication post handled authentication. This post adds role-based access control (RBAC) — controlling which endpoints and operations each user can access.
Roles vs authorities
Spring Security has two related concepts:
- Authority — a permission string, like
READ_PRODUCTSorDELETE_USERS - Role — a convenience wrapper that prefixes
ROLE_to an authority
// These are equivalent
SimpleGrantedAuthority("ROLE_ADMIN")
// and using hasRole("ADMIN") in security expressions
When you call hasRole("ADMIN"), Spring checks for the authority ROLE_ADMIN. When you call hasAuthority("READ_PRODUCTS"), Spring checks for the exact string.
Use roles for broad access levels (ADMIN, USER, MODERATOR). Use authorities for fine-grained permissions (CREATE_PRODUCT, DELETE_USER).
Database model for RBAC
Entities
package com.example.demo.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.JoinTable
import jakarta.persistence.ManyToMany
import jakarta.persistence.Table
import java.util.UUID
@Entity
@Table(name = "roles")
class AppRole(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
val id: UUID? = null,
@Column(nullable = false, unique = true)
val name: String,
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = [JoinColumn(name = "role_id")],
inverseJoinColumns = [JoinColumn(name = "permission_id")]
)
val permissions: Set<Permission> = emptySet()
)
@Entity
@Table(name = "permissions")
class Permission(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
val id: UUID? = null,
@Column(nullable = false, unique = true)
val name: String
)
Update the user entity to reference roles:
@Entity
@Table(name = "users")
class AppUser(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
val id: UUID? = null,
@Column(nullable = false, unique = true)
val email: String,
@Column(nullable = false)
val passwordHash: String,
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = [JoinColumn(name = "user_id")],
inverseJoinColumns = [JoinColumn(name = "role_id")]
)
val roles: Set<AppRole> = emptySet()
)
Database migration
-- V3__create_rbac_tables.sql
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id),
permission_id UUID NOT NULL REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id),
role_id UUID NOT NULL REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
-- Seed default roles and permissions
INSERT INTO permissions (name) VALUES
('READ_PRODUCTS'),
('CREATE_PRODUCT'),
('UPDATE_PRODUCT'),
('DELETE_PRODUCT'),
('READ_USERS'),
('CREATE_USER'),
('DELETE_USER');
INSERT INTO roles (name) VALUES ('ADMIN'), ('EDITOR'), ('VIEWER');
-- Admin gets all permissions
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = 'ADMIN';
-- Editor gets product CRUD
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = 'EDITOR' AND p.name IN ('READ_PRODUCTS', 'CREATE_PRODUCT', 'UPDATE_PRODUCT');
-- Viewer gets read-only
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = 'VIEWER' AND p.name = 'READ_PRODUCTS';
Loading authorities from the database
Update UserDetailsService to load roles and permissions:
package com.example.demo.security
import com.example.demo.repository.UserRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service
class CustomUserDetailsService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val user = userRepository.findByEmail(username)
?: throw UsernameNotFoundException("User not found: $username")
val authorities = mutableListOf<SimpleGrantedAuthority>()
for (role in user.roles) {
// Add the role itself (ROLE_ADMIN, ROLE_EDITOR, etc.)
authorities.add(SimpleGrantedAuthority("ROLE_${role.name}"))
// Add individual permissions
for (permission in role.permissions) {
authorities.add(SimpleGrantedAuthority(permission.name))
}
}
return User.builder()
.username(user.email)
.password(user.passwordHash)
.authorities(authorities)
.build()
}
}
URL-level security
Configure which roles can access which URL patterns:
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/v1/**").hasRole("ADMIN")
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
.build()
}
Rules are evaluated in order. Put specific rules first, general rules last.
Method-level security with @PreAuthorize
For fine-grained control, use @PreAuthorize on individual methods. This requires @EnableMethodSecurity on your security config.
Basic role checks
package com.example.demo.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/products")
class ProductController(
private val productService: ProductService
) {
@GetMapping
fun list(): ResponseEntity<List<ProductResponse>> {
return ResponseEntity.ok(productService.findAll())
}
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasRole('EDITOR')")
fun create(@RequestBody request: CreateProductRequest): ResponseEntity<ProductResponse> {
return ResponseEntity.ok(productService.create(request))
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
productService.delete(id)
return ResponseEntity.noContent().build()
}
}
Authority-based checks
@PostMapping
@PreAuthorize("hasAuthority('CREATE_PRODUCT')")
fun create(@RequestBody request: CreateProductRequest): ResponseEntity<ProductResponse> {
return ResponseEntity.ok(productService.create(request))
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('DELETE_PRODUCT')")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
productService.delete(id)
return ResponseEntity.noContent().build()
}
Authority checks are more flexible than role checks. If you add a new role that should be able to delete products, you just assign the DELETE_PRODUCT permission to it — no code changes.
SpEL expressions
@PreAuthorize uses Spring Expression Language (SpEL). You can write complex conditions:
// User can only update their own profile
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
fun updateUser(@PathVariable id: UUID, @RequestBody request: UpdateUserRequest): ResponseEntity<UserResponse> {
return ResponseEntity.ok(userService.update(id, request))
}
// Multiple conditions
@PostMapping
@PreAuthorize("hasAuthority('CREATE_PRODUCT') and #request.price <= 10000")
fun create(@RequestBody request: CreateProductRequest): ResponseEntity<ProductResponse> {
return ResponseEntity.ok(productService.create(request))
}
@PostAuthorize — check after execution
Sometimes you need to check the return value:
@GetMapping("/{id}")
@PostAuthorize("returnObject.body.ownerId == authentication.principal.username or hasRole('ADMIN')")
fun getOrder(@PathVariable id: UUID): ResponseEntity<OrderResponse> {
return ResponseEntity.ok(orderService.findById(id))
}
The method runs first, then the authorization check happens on the result. Use sparingly — the operation has already executed.
Hierarchical roles
If ADMIN should automatically have all EDITOR permissions, and EDITOR should have all VIEWER permissions, define a role hierarchy:
package com.example.demo.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.access.hierarchicalroles.RoleHierarchy
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl
@Configuration
class RoleConfig {
@Bean
fun roleHierarchy(): RoleHierarchy {
return RoleHierarchyImpl.fromHierarchy(
"""
ROLE_ADMIN > ROLE_EDITOR
ROLE_EDITOR > ROLE_VIEWER
""".trimIndent()
)
}
}
Now hasRole('VIEWER') passes for ADMIN and EDITOR users too. This eliminates hasRole('ADMIN') or hasRole('EDITOR') or hasRole('VIEWER') chains.
Service-layer security
Don’t limit security checks to controllers. Apply them at the service layer too — this protects against internal callers bypassing the controller:
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@PreAuthorize("hasAuthority('CREATE_PRODUCT')")
fun create(request: CreateProductRequest): ProductResponse {
val product = Product(
name = request.name,
price = request.price
)
return productRepository.save(product).toResponse()
}
@PreAuthorize("hasAuthority('DELETE_PRODUCT')")
fun delete(id: UUID) {
productRepository.deleteById(id)
}
}
Custom security expressions
For reusable security logic, create a custom security evaluator:
package com.example.demo.security
import com.example.demo.repository.ProductRepository
import org.springframework.security.core.Authentication
import org.springframework.stereotype.Component
import java.util.UUID
@Component("productSecurity")
class ProductSecurityEvaluator(
private val productRepository: ProductRepository
) {
fun isOwner(authentication: Authentication, productId: UUID): Boolean {
val product = productRepository.findById(productId).orElse(null) ?: return false
return product.createdBy == authentication.name
}
}
Use it in @PreAuthorize:
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @productSecurity.isOwner(authentication, #id)")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
productService.delete(id)
return ResponseEntity.noContent().build()
}
The @productSecurity references the Spring bean by name.
Testing secured endpoints
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
@WebMvcTest(ProductController::class)
class ProductControllerTest(
@Autowired private val mockMvc: MockMvc
) {
@Test
fun `admin can create products`() {
mockMvc.post("/api/v1/products") {
with(jwt().authorities(SimpleGrantedAuthority("ROLE_ADMIN")))
contentType = MediaType.APPLICATION_JSON
content = """{"name": "Widget", "price": 9.99}"""
}.andExpect {
status { isOk() }
}
}
@Test
fun `viewer cannot create products`() {
mockMvc.post("/api/v1/products") {
with(jwt().authorities(SimpleGrantedAuthority("ROLE_VIEWER")))
contentType = MediaType.APPLICATION_JSON
content = """{"name": "Widget", "price": 9.99}"""
}.andExpect {
status { isForbidden() }
}
}
@Test
fun `unauthenticated user gets 401`() {
mockMvc.post("/api/v1/products") {
contentType = MediaType.APPLICATION_JSON
content = """{"name": "Widget", "price": 9.99}"""
}.andExpect {
status { isUnauthorized() }
}
}
}
Common mistakes
Checking roles in business logic. Don’t write if (user.role == "ADMIN") in your service. Use @PreAuthorize — it keeps authorization declarative and testable.
Using EAGER fetch on roles in production. The examples use EAGER for simplicity. In production, use a custom query with JOIN FETCH to control when roles are loaded.
Not testing authorization. Every secured endpoint needs tests for allowed roles, denied roles, and unauthenticated access. Three tests minimum per secured endpoint.
Hardcoding permissions in code. Store permissions in the database. When you need a new permission, add a migration — not a code change.
RBAC gives you flexible, maintainable authorization. Start with roles for broad access control, add fine-grained permissions when roles aren’t enough, and always test your security rules.