Spring Boot API Security: Connecting Authentication to Authorization
When building APIs quickly, it is common to validate that a token exists and then proceed. That pattern creates a dangerous gap: authentication proves who the caller is, but authorization decides what the caller is allowed to do.
This page connects the full path from incoming request to allow or deny decision in a Spring Boot microservices architecture, with a GCP-oriented deployment model.
The Dot-Connection You Need
sequenceDiagram
autonumber
participant C as Client
participant GW as API Gateway (Optional)
participant API as Spring Boot API
participant IDP as IdP (OIDC Provider)
participant AUTHZ as Authorization Rules
C->>GW: Request with Bearer token
GW->>API: Forward request + token
API->>IDP: Validate JWT signature via JWK set
IDP-->>API: Signature and claims are valid
API->>AUTHZ: Evaluate scopes, roles, tenant, resource ownership
AUTHZ-->>API: Allow or Deny
API-->>C: 200/403
Key point:
- Authentication: Is this token valid and trustworthy?
- Authorization: Can this identity perform this action on this resource?
You need both.
Common Anti-Pattern in Microservices
Anti-pattern:
- API receives token.
- API calls a security microservice only to parse or verify token.
- API trusts success and allows business action.
Why this fails:
- It checks identity presence, not permission boundaries.
- It often skips resource-level checks such as tenant ownership.
- It can become fail-open when the security service is degraded.
Better approach:
- Let each API act as a Spring Security Resource Server.
- Validate JWT locally using issuer and JWK metadata.
- Enforce authorization in the API using scopes, roles, and domain checks.
- Keep central policy definitions, but enforce at the edge of each service.
Spring Security Request Flow
flowchart TB
A[HTTP Request] --> B[BearerTokenAuthenticationFilter]
B --> C[JwtDecoder validates signature and issuer]
C --> D[JwtAuthenticationToken in SecurityContext]
D --> E[AuthorizationManager or Method Security]
E --> F{Allowed?}
F -->|Yes| G[Controller + Service]
F -->|No| H[403 Forbidden]
Minimal Secure Configuration (Spring Boot 3)
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
.requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_orders.write")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
);
return http.build();
}
@Bean
Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthorityPrefix("SCOPE_");
scopes.setAuthoritiesClaimName("scope");
return jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
// Optional: map custom claim roles -> ROLE_* authorities
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
return new JwtAuthenticationToken(jwt, authorities);
};
}
}
application.yaml:
Resource-Level Authorization (The Part Teams Miss)
Scopes alone are not enough. You also need domain checks such as tenant and ownership.
@Service
public class OrderAuthorizationService {
public void assertCanReadOrder(JwtAuthenticationToken auth, Order order) {
String callerTenant = auth.getToken().getClaimAsString("tenant_id");
if (!order.getTenantId().equals(callerTenant)) {
throw new AccessDeniedException("Cross-tenant access denied");
}
}
}
Use it in service methods:
@Service
public class OrderService {
private final OrderRepository repository;
private final OrderAuthorizationService orderAuthz;
public OrderService(OrderRepository repository, OrderAuthorizationService orderAuthz) {
this.repository = repository;
this.orderAuthz = orderAuthz;
}
@PreAuthorize("hasAuthority('SCOPE_orders.read')")
public Order getOrder(String id, JwtAuthenticationToken auth) {
Order order = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Order not found"));
orderAuthz.assertCanReadOrder(auth, order);
return order;
}
}
GCP Deployment Mapping
| Security Need | Spring Boot API | GCP Layer |
|---|---|---|
| Token trust | Resource Server JWT validation | Identity provider + OIDC metadata |
| Network protection | TLS + route guards | API Gateway, Cloud Armor |
| Secret handling | No embedded credentials | Secret Manager |
| Service identity | Workload identity over static keys | GCP Workload Identity Federation |
| Audit and forensics | AuthN/AuthZ decision logs | Cloud Logging + SCC correlation |
Failure Modes and Remediation Steps
| Failure Mode | What It Looks Like | Remediation |
|---|---|---|
| Token presence only | Any valid token can access privileged API | Add route-level authorities and method-level checks |
| Missing tenant boundary | Cross-tenant data exposure | Enforce tenant claim to resource tenant match |
| Fail-open auth service dependency | Security checks bypassed during outage | Validate JWT locally in each API and fail closed |
| Over-trusted internal network | East-west calls skip auth headers | Require mTLS and token validation for service-to-service calls |
| Broad role mapping | Users get excessive permissions | Use least-privilege scopes and explicit role mapping |
Practical Test Cases You Should Keep in CI
# 1) Missing token should be denied
curl -i https://api.example.com/api/orders/123
# 2) Wrong scope should be denied
curl -i -H "Authorization: Bearer $TOKEN_WITHOUT_ORDERS_READ" \
https://api.example.com/api/orders/123
# 3) Cross-tenant token should be denied
curl -i -H "Authorization: Bearer $OTHER_TENANT_TOKEN" \
https://api.example.com/api/orders/123
Expected:
- Missing token: 401
- Wrong scope: 403
- Cross-tenant access: 403
Security Checklist for Every New API
- Have I configured the API as a JWT resource server?
- Do I enforce route-level scope or authority checks?
- Do I enforce resource-level checks such as tenant or ownership?
- Do denied requests return 401 or 403 correctly?
- Are AuthN/AuthZ decisions logged with request correlation IDs?
- Do CI tests include negative authorization cases?
Day-to-Day Engineering Guardrails for Spring APIs
These habits prevent most authorization bugs before code review.
| Layer | Guardrail | Why It Prevents Security Bugs |
|---|---|---|
| Controller | Keep controller thin and delegate decisions to service methods with explicit auth context | Reduces accidental bypass logic in endpoints |
| Service | Enforce business authorization in domain services, not only URL rules | Catches resource-level abuse paths |
| Repository | Always query with tenant or ownership predicates for sensitive data | Prevents IDOR and cross-tenant leakage |
| DTO mapping | Use allow-list field mapping for updates | Prevents mass assignment privilege escalation |
| Error handling | Return generic access errors without leaking policy internals | Limits attacker feedback for enumeration |
Example tenant-safe repository pattern:
public interface OrderRepository extends JpaRepository<Order, String> {
Optional<Order> findByIdAndTenantId(String id, String tenantId);
}
@PreAuthorize("hasAuthority('SCOPE_orders.read')")
public Order getOrderSafe(String id, JwtAuthenticationToken auth) {
String tenantId = auth.getToken().getClaimAsString("tenant_id");
return repository.findByIdAndTenantId(id, tenantId)
.orElseThrow(() -> new AccessDeniedException("Order not accessible"));
}
Method Security for Domain Rules
Route-level checks are not enough when permissions depend on resource ownership or state.
@Component("orderPolicy")
public class OrderPolicy {
public boolean canCancel(JwtAuthenticationToken auth, Order order) {
String tenant = auth.getToken().getClaimAsString("tenant_id");
boolean sameTenant = tenant.equals(order.getTenantId());
boolean hasScope = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("SCOPE_orders.cancel"));
boolean cancellable = !order.getStatus().isTerminal();
return sameTenant && hasScope && cancellable;
}
}
@PreAuthorize("@orderPolicy.canCancel(authentication, #order)")
public void cancelOrder(Order order) {
// business action
}
Security Tests You Should Run on Every Pull Request
Use spring-security-test so auth behavior is verified automatically in CI.
@AutoConfigureMockMvc
@SpringBootTest
class OrderSecurityTests {
@Autowired
MockMvc mvc;
@Test
void missingToken_is401() throws Exception {
mvc.perform(get("/api/orders/123"))
.andExpect(status().isUnauthorized());
}
@Test
void wrongScope_is403() throws Exception {
mvc.perform(get("/api/orders/123")
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_profile.read"))))
.andExpect(status().isForbidden());
}
@Test
void validScopeAndTenant_is200() throws Exception {
mvc.perform(get("/api/orders/123")
.with(jwt().jwt(j -> j.claim("tenant_id", "tenant-a"))
.authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
.andExpect(status().isOk());
}
}
Practical Anti-Patterns and Safer Replacements
| Anti-Pattern | Safer Replacement |
|---|---|
| Parse token in custom filter and trust userId header | Use Resource Server JWT verification + SecurityContext |
| Global admin role for all internal jobs | Dedicated service accounts and narrow scopes per job |
| Checking role only at controller | Enforce policy again in service/domain layer |
| Returning full object before authz filter | Fetch only authorized resource using tenant-aware query |
| Accepting arbitrary fields in PATCH update | Validate input and map only allowed fields |
A Simple Decision Rule
If your API only answers the question "is this caller authenticated?" and not "is this caller authorized for this exact action and resource?", the API is incomplete from a security standpoint.