Spring Authorization Server vs Keycloak: An Honest Comparison
Spring Authorization Server vs Keycloak: An Honest Comparison
The Assumption
One tool is better. The assumption: there is a universally correct choice between building your authorization server and using a pre-built one.
The correct choice depends on exactly three variables: the customization depth you need, the security expertise on your team, and the operational capacity you have for running authentication infrastructure. Everything else is preference.
The Attack
This section does not describe a security attack. It describes an organizational failure mode.
Scenario 1: The half-built auth server. A team of four starts building with Spring Authorization Server. The lead engineer understands OAuth2 deeply. Login works. Token issuance works. Three months in, the lead engineer leaves. The remaining team understands the framework surface but not the security model underneath. They add a feature (custom claim) that accidentally exposes tenant IDs across tenant boundaries. The vulnerability is not detected for 8 months because nobody on the team knows what to test for.
Scenario 2: The over-configured vendor. A team buys Auth0. Initial setup takes 2 days. Impressive. Six months later, they need custom token claims for their multi-tenant model. Auth0 Actions can do this, but the logic is in Auth0’s dashboard, not in the codebase. It cannot be tested locally. It cannot be code-reviewed in a pull request. A junior developer changes an Action and accidentally removes the tenant_id claim. Tokens are issued without tenant isolation. The bug is in Auth0’s UI, not in the application’s test suite, so no test catches it.
Both scenarios result from the same root cause: the team operated outside its competence boundary.
The Spec or Mechanism
Decision Framework
Variable 1: Customization depth.
| Customization need | Build score | Buy score |
|---|---|---|
| Standard OAuth2/OIDC flows | 0 | +3 |
| Custom token claims | +1 | +2 |
| Custom authentication flows (step-up, risk-based) | +3 | +1 |
| Custom token format or binding (DPoP) | +3 | 0 |
| Multi-tenant with tenant-specific auth policies | +3 | +1 |
| Federation with legacy SAML providers | +1 | +3 |
Variable 2: Team expertise.
| Team expertise | Build score | Buy score |
|---|---|---|
| Deep Spring Security knowledge (multiple engineers) | +3 | 0 |
| OAuth2/OIDC spec knowledge | +2 | +1 |
| General web security knowledge only | 0 | +3 |
| No dedicated security engineer | -2 | +3 |
Variable 3: Operational capacity.
| Operational capacity | Build score | Buy score |
|---|---|---|
| Dedicated infrastructure/SRE team | +2 | 0 |
| On-call rotation includes auth system | +2 | 0 |
| Can handle 3 AM key rotation incident | +2 | 0 |
| Small ops team, limited on-call | -2 | +3 |
| No ops team (developer-run infrastructure) | -3 | +3 |
Total the scores. If “build” scores higher, build. If “buy” scores higher, buy. If scores are close, buy. The tie goes to buy because the cost of a security mistake when building is higher than the cost of vendor lock-in when buying.
The Implementation
Build: Spring Authorization Server Baseline
// What a "build" implementation requires at minimum:
// 1. Authorization server configuration
@Configuration
public class AuthServerConfig {
@Bean
public RegisteredClientRepository registeredClientRepository(
PasswordEncoder passwordEncoder) {
// Every client registered with exact redirect URIs (CH11-S2)
// Scopes defined per client (CH11-S2)
// Client authentication method configured (CH9-S2)
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
@Bean
public JWKSource<SecurityContext> jwkSource(
KeyRotationService keyRotationService) {
// Multi-key source with rotation support (CH13)
return keyRotationService::getCurrentJwkSet;
}
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(
TenantService tenantService) {
// Custom claims for multi-tenancy (CH4-S2, CH8)
return context -> {
String userId = context.getPrincipal().getName();
context.getClaims().claim("tenant_id",
tenantService.getTenantForUser(userId));
};
}
}
// 2. Token lifecycle management (CH5)
// 3. Session management (CH7)
// 4. Multi-tenant isolation (CH8)
// 5. Key rotation automation (CH13)
// 6. Audit logging (CH14)
// 7. Rate limiting (CH12)
// 8. All of the above, tested, monitored, and maintained
Buy: Keycloak with Spring Resource Server
// What a "buy" implementation looks like:
@Configuration
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri("https://keycloak.saas.example/realms/saas/protocol/openid-connect/certs")
.jwtAuthenticationConverter(keycloakJwtConverter())
)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/**").authenticated()
)
.build();
}
// Convert Keycloak's realm_access.roles to Spring Security authorities
private JwtAuthenticationConverter keycloakJwtConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess == null) return Collections.emptyList();
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
});
return converter;
}
}
// Keycloak handles:
// - Token issuance and refresh
// - Key rotation (automatic)
// - Session management
// - Federation (OIDC, SAML built-in)
// - User management UI
// - Password policies
//
// Your application handles:
// - Resource authorization (scope/role enforcement)
// - Multi-tenant data isolation
// - Business-specific audit logging
// - Keycloak claim-to-domain mapping
The Verification
The verification for a build/buy decision is not a unit test. It is a decision audit.
## Auth Architecture Decision Record
**Date:** 2024-07-15
**Decision:** Build with Spring Authorization Server
**Status:** Accepted
### Context
- Multi-tenant SaaS with 200+ tenants
- Tenant-specific auth policies (some require SAML, some OIDC, some MFA)
- 3 engineers with deep Spring Security experience
- Dedicated SRE team with 24/7 on-call
### Decision Variables
- Customization: +9 (build) vs +4 (buy)
- Expertise: +7 (build) vs +1 (buy)
- Operations: +6 (build) vs 0 (buy)
- Total: Build 22, Buy 5
### Consequences
- Must maintain key rotation automation (CH13)
- Must maintain security event logging (CH14)
- Must apply Spring Security patches within 48 hours of release
- Must have at least 2 engineers who can debug token validation failures
- Reviewed annually: if team expertise drops below threshold, migrate to vendor
### Alternatives Considered
- Keycloak: rejected because tenant-specific auth policies require
custom SPIs that approach the complexity of building from scratch
- Auth0: rejected because Actions cannot be tested in CI pipeline
The decision record is the verification. It documents the reasoning, the scores, and the consequences. When the team composition changes (the lead engineer leaves), the decision record triggers re-evaluation. When the operational burden exceeds expectations, the decision record provides the criteria for reconsidering.