Enterprise API Security in Spring Boot: OAuth 2.1, mTLS & Signature Verification
This article details a comprehensive enterprise API security implementation using Spring Boot, covering OAuth 2.1 with PKCE and token exchange, mutual TLS for transport security, API signature verification to prevent tampering and replay, JWT encryption with JWE, fine-grained permission control, security auditing, gateway-level authentication, and alignment with OWASP API Top 10 vulnerabilities.
1. OAuth 2.1 with Spring Authorization Server
OAuth 2.1 is a patch to 2.0 that mandates PKCE, removes insecure implicit and password grants, and forces refresh token rotation. Spring Authorization Server (SAS) natively supports the 2.1 spec.
Registering a client requires encrypted secrets (e.g., bcrypt) — never use {noop} in production. The example below shows a RegisteredClientRepository bean configuring a client with authorization code and refresh token grants, PKCE enforcement ( requireProofKey(true)), consent requirement, 15-minute access token TTL, 7-day refresh token TTL, and refresh token reuse disabled ( reuseRefreshTokens(false)) per OAuth 2.1.
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("mobile-app-client")
.clientSecret("{bcrypt}$2a$10$...") // production must use encryption like bcrypt
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://myapp.com/callback")
.scope(OidcScopes.OPENID)
.scope("api:read")
// OAuth 2.1 mandatory: client must support PKCE
.clientSettings(ClientSettings.builder()
.requireProofKey(true)
.requireAuthorizationConsent(true)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofDays(7))
.reuseRefreshTokens(false) // 2.1 mandatory: disable refresh token reuse, one-time use
.build())
.build();
return new JdbcRegisteredClientRepository(jdbcTemplate);
}2. How PKCE Prevents Authorization Code Interception
PKCE (Proof Key for Code Exchange) is central to OAuth 2.1. The client generates a high-entropy code_verifier (43–128 chars), computes its SHA-256 hash as code_challenge, and sends the challenge during authorization. When exchanging the code for a token, the client sends the original code_verifier; the server verifies the hash matches.
The article warns against implementing PKCE manually in front-end or mobile apps due to risks in random generation and Base64 encoding — use established OIDC SDKs instead. The following Java snippet illustrates the underlying logic:
// 1. Generate high-entropy code_verifier (43-128 chars, must use secure random)
String codeVerifier = generateSecureRandomString(64);
// 2. Compute SHA-256, then Base64 URL no-padding encoding
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
// 3. Include challenge in authorization request
// GET /oauth2/authorize?...&code_challenge=xxx&code_challenge_method=S2563. Service-to-Service Authentication: Token Exchange
For system-level calls, Client Credentials flow suffices. When Service A must call Service B on behalf of a user, Token Exchange (RFC 8693) is used. Service A presents the user's access token to the authorization server and receives a new token containing the original user identity (typically in JWT act or sub claims). This avoids exposing the user's raw token to downstream services.
// Service A initiates Token Exchange
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
params.add("subject_token", userAccessToken);
params.add("subject_token_type", "urn:ietf:params:oauth:token-type:access_token");
params.add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token");
params.add("audience", "service-b");4. mTLS: Hardened Transport-Layer Authentication
mTLS ensures only clients with valid certificates can establish a TCP connection, eliminating man-in-the-middle attacks. In Spring Boot, enable mTLS via application.yml:
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: changeit
key-store-type: PKCS12
trust-store: classpath:truststore.p12
trust-store-password: changeit
client-auth: need # forces client certificate presentationHard lesson on certificate hot-reloading: Many guides suggest a WebServerFactoryCustomizer to watch file changes and reload the TrustStore dynamically. The author strongly advises against this in the Java application layer — Tomcat's SSL context refresh has many subtle pitfalls. In production, offload mTLS termination to a sidecar proxy (Nginx, Envoy) and let the Java app handle only business logic.
5. API Signatures: Baseline Against Tampering and Replay
For public APIs, tokens alone are insufficient; attackers can capture and replay legitimate requests. API signatures provide integrity and replay protection. Design principles:
Internal microservices: HMAC-SHA256 (symmetric, fast). External APIs: RSA-SHA256 (asymmetric, private key signs, public key verifies).
Every request must include a Timestamp (expiry check) and Nonce (replay prevention).
The interceptor skeleton below highlights a critical pitfall: reading the request InputStream for body signing consumes the stream, leaving nothing for the controller. The fix is to wrap the request with ContentCachingRequestWrapper.
@Component
public class ApiSignatureInterceptor implements HandlerInterceptor {
@Autowired
private NonceCacheService nonceCacheService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String appId = request.getHeader("X-App-Id");
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
// 1. Validate timestamp, reject if drift > 5 minutes
long reqTime = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - reqTime) > 5 * 60 * 1000) {
throw new SecurityException("Request expired");
}
// 2. Validate nonce via Redis setIfAbsent with 10-minute TTL
if (!nonceCacheService.setIfAbsent(appId + ":" + nonce, "1", 10, TimeUnit.MINUTES)) {
throw new SecurityException("Duplicate request");
}
// 3. Build string-to-sign (parameters sorted lexicographically, include body for POST)
String stringToSign = buildStringToSign(request, appId, timestamp, nonce);
// 4. Verify signature
String publicKey = getAppPublicKey(appId);
if (!RsaUtils.verify(stringToSign, signature, publicKey)) {
throw new SecurityException("Invalid signature");
}
return true;
}
}6. JWT Naked Exposure and JWE Encryption
JWT is only Base64-encoded, not encrypted. Anyone with a token can decode its payload and see emails, roles, phone numbers. If sensitive data must be in the token, use JWE (JSON Web Encryption). SAS supports JWE configuration: RSA encrypts an AES key, then AES-GCM encrypts the payload.
However, JWE adds CPU overhead. For high-throughput internal endpoints, avoid JWE; store sensitive data in Redis and use opaque tokens instead. Reserve JWE for external APIs or scenarios requiring sensitive claims in the token.
OAuth 2.1 also mandates refresh token rotation: each access token refresh must issue a new refresh token and invalidate the old one. Implement the /oauth2/revoke endpoint to revoke tokens when users change passwords or lose devices.
7. Authorization: Don't Confuse Scope and Authority
Coarse-grained roles like admin/user are insufficient. In Spring Security, distinguish:
Scope : client-level, determines which APIs an app can call (e.g., api:read).
Authority : user-level, determines what a user can do within the app (e.g., ROLE_MANAGER).
A common pitfall: OAuth2 scopes are converted to authorities with a SCOPE_ prefix. The controller must use hasAuthority('SCOPE_api:read'), not hasAuthority('api:read'). Combined with a custom SpEL expression ( @orderPermissionEvaluator), this enables row-level data permissions, eliminating broken object-level authorization (BOLA).
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// Note the SCOPE_api:read prefix; omitting it causes perpetual authorization failure
@PreAuthorize("hasAuthority('SCOPE_api:read') and " +
"hasAuthority('ROLE_SALES') and " +
"@orderPermissionEvaluator.canRead(authentication, #orderId)")
@GetMapping("/{orderId}")
public Order getOrder(@PathVariable String orderId) {
return orderService.findById(orderId);
}
}8. Security Auditing: Don't Just Write to Database
Spring Security publishes authentication and authorization events. Listen to them for audit logging:
@Component
public class SecurityAuditListener {
@Autowired
private AuditLogService auditLogService;
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
auditLogService.logAsync("LOGIN_SUCCESS", event.getAuthentication().getName());
}
@EventListener
public void onFailure(AuthorizationFailureEvent event) {
auditLogService.logAsync("ACCESS_DENIED", event.getAuthentication().getName());
}
}Practical advice: Never write audit logs synchronously to the database — high concurrency will overwhelm it. Use an async thread pool or push logs to Kafka, then feed into ELK or a dedicated audit system. From these logs, build alerts for geo-impossible logins, repeated failures, etc., to block low-level scanning.
9. Gateway Centralized Authentication: Don't Burden Downstream Services
In microservices, having every downstream service parse and validate JWT wastes resources. The best practice is to handle authentication at the Spring Cloud Gateway layer.
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private ReactiveJwtDecoder jwtDecoder;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = extractToken(exchange.getRequest());
if (token == null) {
return onError(exchange, HttpStatus.UNAUTHORIZED);
}
return jwtDecoder.decode(token)
.flatMap(jwt -> {
// On success, inject user info into headers for downstream
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-User-Id", jwt.getClaimAsString("sub"))
.header("X-User-Roles", String.join(",", jwt.getClaimAsStringList("roles")))
.build();
return chain.filter(exchange.mutate().request(request).build());
})
.onErrorResume(e -> onError(exchange, HttpStatus.UNAUTHORIZED));
}
@Override
public int getOrder() {
return -100; // run early
}
}Critical reminder: After the gateway forwards user info via headers, downstream services must enforce network policies allowing only gateway IPs. Otherwise, attackers can bypass the gateway and forge X-User-Id headers, rendering authorization useless.
10. Mapping to OWASP API Top 10
The proposed stack addresses core OWASP API Security Top 10 risks:
BOLA (Broken Object Level Authorization) — #1 API vulnerability. Mitigated by @PreAuthorize with data-ownership checks ( orderPermissionEvaluator) ensuring users only access their own resources.
Broken Authentication — OAuth 2.1 PKCE prevents code interception; mTLS prevents transport sniffing; JWE prevents token payload leakage.
Mass Assignment — Use strict DTOs with Jackson @JsonView or @JsonIgnore; never deserialize request bodies directly into entities.
Security Misconfiguration — Automate via CI/CD: Trivy for image scanning, Checkov for Kubernetes config, certificate expiry alerts, no default passwords in configs.
Security is a bottomless pit; no one-time solution exists. Daily scans and patches are the norm. Solidifying these fundamentals eliminates low-hanging vulnerabilities detectable by automated tools and buys time against skilled attackers — already ahead of most teams.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
