How to Implement RBAC with Spring Security in Multi-Tenant Spring Boot Applications
I was building a multi-tenant SaaS application when I hit a wall: how do I implement Role-Based Access Control (RBAC) while ensuring tenant isolation? The standard Spring Security tutorials showed me how to do RBAC, and separate guides showed tenant isolation, but combining them correctly was the challenge.
The Problem: Two Security Layers, One Application
Here’s what happened: I had a Spring Boot app where different organizations (tenants) shared the same database, but needed complete data isolation. Within each tenant, users had different roles: admins, managers, viewers, etc.
The naive approach I tried first:
@GetMapping("/documents/{id}")public Document getDocument(@PathVariable Long id) { String tenantId = request.getHeader("X-Tenant-Id"); // From client! return documentService.findByIdAndTenant(id, tenantId);}This is completely wrong. A malicious user can simply change the header and access other tenants’ data. I needed a security architecture that:
- Authenticates users with their tenant context
- Authorizes actions based on roles
- Filters data by tenant at the repository level
Layer 1: Authentication with Tenant Context
The first realization was that tenant information needs to be part of the authentication process, not an afterthought. I extended Spring Security’s authentication token:
public class TenantAuthenticationToken extends UsernamePasswordAuthenticationToken { private final String tenantId;
public TenantAuthenticationToken(Object principal, Object credentials, String tenantId, Collection<? extends GrantedAuthority> authorities) { super(principal, credentials, authorities); this.tenantId = tenantId; }
public String getTenantId() { return tenantId; }}This ensures that once authenticated, the tenant context is immutable and travels with the user’s session.
Layer 2: Thread-Local Tenant Context
For accessing tenant information throughout the request lifecycle, I created a ThreadLocal holder:
public class TenantContext { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); }
public static String getTenantId() { return CURRENT_TENANT.get(); }
public static void clear() { CURRENT_TENANT.remove(); }}Critical mistake I made: I initially forgot to call clear() in a finally block, which caused tenant context to leak between requests in the thread pool. Always clean up ThreadLocal:
@Componentpublic class TenantSecurityFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { try { String token = extractToken(request); if (token != null) { Claims claims = jwtService.parseToken(token); String tenantId = claims.get("tenant_id", String.class); TenantContext.setTenantId(tenantId); } filterChain.doFilter(request, response); } finally { TenantContext.clear(); // NEVER forget this! } }}Layer 3: Custom PermissionEvaluator
Now comes the RBAC part. Spring Security’s @PreAuthorize is powerful, but I needed it to understand both roles AND tenant boundaries. I created a custom PermissionEvaluator:
@Componentpublic class TenantAwarePermissionEvaluator implements PermissionEvaluator {
@Override public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) { if (auth instanceof TenantAuthenticationToken tenantAuth) { String userTenant = tenantAuth.getTenantId(); String resourceTenant = extractTenantFromResource(targetDomainObject);
// Must match BOTH tenant and permission return userTenant.equals(resourceTenant) && checkRolePermission(auth, permission); } return false; }
@Override public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) { return validateTenantAndPermission(auth, targetId, targetType, permission); }}This allows me to write authorization checks like:
@Servicepublic class DocumentService {
@PreAuthorize("hasRole('DOCUMENT_MANAGER') and @tenantSecurity.isTenantResource(#documentId)") public Document updateDocument(Long documentId, DocumentDto dto) { Document doc = documentRepository.findById(documentId) .orElseThrow(() -> new NotFoundException("Document not found"));
validateTenantOwnership(doc); // Double-check at service level
doc.setContent(dto.getContent()); return documentRepository.save(doc); }}The helper bean:
@Component("tenantSecurity")public class TenantSecurityHelper {
public boolean isTenantResource(Long resourceId) { String userTenant = TenantContext.getTenantId(); String resourceTenant = resourceRepository.findTenantIdById(resourceId); return userTenant.equals(resourceTenant); }}Layer 4: Repository-Level Tenant Filtering
The most important layer: never trust the service layer alone. Repository-level filtering ensures that even if a bug in the service layer tries to fetch data from another tenant, it simply won’t find it.
I initially wrote manual queries everywhere, but that was error-prone. Then I discovered Hibernate filters:
@Entity@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")public class BaseEntity { @Id @GeneratedValue private Long id;
@Column(name = "tenant_id") private String tenantId;}Enable the filter automatically:
@Componentpublic class TenantFilterEnabler {
@Autowired private EntityManager entityManager;
@Transactional public void enableTenantFilter() { Session session = entityManager.unwrap(Session.class); String tenantId = TenantContext.getTenantId(); if (tenantId != null) { session.enableFilter("tenantFilter") .setParameter("tenantId", tenantId); } }}For custom repository methods:
@Repositorypublic class TenantAwareRepositoryImpl implements TenantAwareRepository {
@PersistenceContext private EntityManager entityManager;
@Override public <T> Optional<T> findById(Class<T> entityClass, Long id) { String tenantId = TenantContext.getTenantId();
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<T> query = cb.createQuery(entityClass); Root<T> root = query.from(entityClass);
query.where( cb.equal(root.get("id"), id), cb.equal(root.get("tenantId"), tenantId) );
return entityManager.createQuery(query) .getResultStream() .findFirst(); }}The Architecture Visualized
+------------------+| HTTP Request |+--------+---------+ | v+--------+---------+| JWT with Tenant | <-- Tenant in token, not from client+--------+---------+ | v+--------+---------+| Security Filter | <-- Extracts tenant, sets ThreadLocal+--------+---------+ | v+--------+---------+| @PreAuthorize | <-- Checks role AND tenant ownership+--------+---------+ | v+--------+---------+| Service Layer | <-- Additional validation+--------+---------+ | v+--------+---------+| Repository Layer | <-- Hibernate filter applied+--------+---------+ | v+--------+---------+| Database | <-- WHERE tenant_id = ?+------------------+Common Mistakes I Made
Mistake 1: Accepting Tenant ID from Client
@PostMapping("/documents")public Document create(@RequestBody DocumentDto dto, @RequestHeader("X-Tenant-Id") String tenantId) { // Attacker can change header to access other tenants!}Correct approach: Extract tenant from authenticated token only.
Mistake 2: Only Checking at API Level
If you only validate tenant at the controller, someone who bypasses the controller (scheduled jobs, internal service calls, message queues) can access wrong data.
Correct approach: Validate at repository level with Hibernate filters.
Mistake 3: Forgetting Background Jobs
When processing background jobs, the ThreadLocal context is not set:
@Asyncpublic void processDocument(Long documentId) { // TenantContext.getTenantId() returns null here! // Need to pass tenant ID as parameter}Mistake 4: Not Using JWT Claims
Initially, I stored tenant ID in a cookie. This was wrong because:
- Cookies can be manipulated
- They don’t work well with API clients
- CORS issues
Correct approach: Store tenant ID as a JWT claim:
public class JwtTenantService {
public String generateToken(String username, String tenantId, List<String> roles) { return Jwts.builder() .subject(username) .claim("tenant_id", tenantId) // Tenant is part of signed token .claim("roles", roles) .issuedAt(new Date()) .expiration(Date.from(Instant.now().plus(1, ChronoUnit.HOURS))) .signWith(secretKey) .compact(); }}Why This Architecture Works
Defense in depth: Each layer catches what the layer above might miss.
Layer 1: Authentication -> Who are you? Which tenant?Layer 2: Authorization -> What can you do? (roles)Layer 3: Service Check -> Is this your resource?Layer 4: Repository -> Only return your tenant's dataLayer 5: Database -> Physical isolation optionAudit trail: Every action is traceable to both user and tenant.
Compliance: Meets GDPR, SOC 2, and other regulatory requirements for data segregation.
Flexibility: Each tenant can have different role configurations while sharing the same codebase.
When to Add Spring Security
From the Reddit discussion that inspired this post, the key insight was:
“If your app needs different data or access based on username/client you want to add it as early as possible.”
This is especially true for multi-tenant RBAC. Retrofitting Spring Security into an existing application is painful. The authentication, authorization, and data filtering layers need to be designed together from the start.
Summary
Multi-tenant RBAC in Spring Security requires:
- Tenant context in authentication - Extend the authentication token
- ThreadLocal for request scope - Access tenant throughout the request
- Custom PermissionEvaluator - Combine role and tenant checks
- Repository-level filtering - Hibernate filters for automatic isolation
- Never trust client input - Always validate against authenticated context
The key insight: RBAC and multi-tenancy are not separate concerns. They must be designed together, with tenant context flowing from authentication through authorization to data access.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Security Authorization Architecture
- 👨💻 Hibernate Multi-Tenancy Documentation
- 👨💻 Reddit: When to add Spring Security?
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments