The Total Cost of Rolling Your Own and the Migration Trap
The Total Cost of Rolling Your Own and the Migration Trap
The Assumption
Building a custom auth server is a one-time cost. The assumption: once it works, maintenance is minimal.
Auth infrastructure is not a feature you ship and move on from. It is a living attack surface that requires continuous investment. New attack vectors emerge. Specs evolve. Dependencies publish security advisories. Your custom auth server requires the same cadence of security attention as the rest of your codebase, but with higher stakes per vulnerability.
The Attack
This is not a technical attack. It is an economic one.
Scenario: Death by a thousand patches. Year 1: the custom auth server works. Year 2: Spring Security 6.2 introduces breaking changes in CSRF handling. The team spends a week upgrading. Month later: a Nimbus JOSE CVE requires an emergency patch. The fix is straightforward but requires regression testing of all token flows. Month later: a new DPoP draft changes the proof format. Clients need updating. Quarter later: a penetration test finds that the session serialization is vulnerable (CH7-S1). The fix requires a rolling migration of all active sessions.
Each patch is small. The cumulative cost is 2-3 engineer-months per year. For a team of 10, that is 2-3% of total engineering capacity dedicated to auth maintenance. For a team of 3, it is 8-10%.
Scenario: The migration trap. A team built on Keycloak. After 2 years, they have 50,000 users, 15 registered clients, and 8 SAML federation integrations. Keycloak’s licensing changes (or operational complexity exceeds their capacity). They decide to migrate to Auth0. Migration requires:
- Export all users (including hashed passwords, which may use Keycloak-specific hashing)
- Recreate all client registrations with new redirect URIs
- Reconfigure all SAML federations (new entity IDs, new metadata, new certificates)
- Update every service’s JWT validation to accept the new issuer
- Coordinate a cutover window where both systems run simultaneously
- Handle users who have active sessions during the cutover
Estimated effort: 3-6 months. During migration, both systems must be maintained.
The Spec or Mechanism
Ongoing Cost Model: Build
| Category | Annual cost (engineer-hours) | Frequency |
|---|---|---|
| Spring Security major version upgrade | 40-80 | Annual |
| Security dependency patches (CVEs) | 20-40 | 4-8 per year |
| Key rotation operations | 10-20 | Quarterly |
| Penetration test remediation | 40-80 | Annual |
| On-call incidents (auth-related) | 20-60 | Continuous |
| Feature requests (new grant types, new federation partners) | 40-120 | Per request |
| Total | 170-400 |
At $150/hour fully loaded, annual maintenance: $25,000-$60,000.
Ongoing Cost Model: Buy
| Category | Annual cost | Frequency |
|---|---|---|
| Vendor subscription (per-MAU pricing) | Varies | Monthly |
| Integration maintenance (vendor API changes) | 20-40 hours | Per change |
| Custom claim/flow updates | 10-30 hours | Per request |
| Vendor incident response (their outage = your outage) | 0-40 hours | Per incident |
| Migration risk reserve | Intangible | Continuous |
| Total (excluding subscription) | 30-110 hours |
At $150/hour: $4,500-$16,500 in engineering time, plus vendor subscription.
The Implementation
Build: Maintenance Automation
// Automated security dependency check (runs in CI)
// build.gradle.kts
plugins {
id("org.owasp.dependencycheck") version "9.0.0"
}
dependencyCheck {
failBuildOnCVSS = 7.0f // Fail build on high/critical CVEs
suppressionFile = "dependency-check-suppressions.xml"
analyzers {
assemblyEnabled = false
}
}
# GitHub Actions: weekly security audit
name: Auth Security Audit
on:
schedule:
- cron: "0 9 * * 1" # Every Monday at 9 AM
jobs:
security-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for Spring Security advisories
run: |
# Check Spring Security GitHub advisories
curl -s "https://api.github.com/repos/spring-projects/spring-security/security-advisories?state=published" \
| jq '.[0:5] | .[] | {severity: .severity, summary: .summary, published: .published_at}'
- name: OWASP Dependency Check
run: ./gradlew dependencyCheckAnalyze
- name: Auth integration tests
run: ./gradlew test --tests '*AuthIntegration*'
- name: Key rotation status check
run: |
# Verify signing key is not older than rotation interval
curl -s https://auth.saas.example/admin/keys/status \
-H "Authorization: Bearer ${{ secrets.ADMIN_TOKEN }}" \
| jq '.signing_key_age_days'
Buy: Vendor Independence Layer
// Abstract the vendor-specific details behind an interface
// This reduces migration cost if you switch vendors
public interface AuthTokenService {
/**
* Extract the tenant ID from the current authentication context.
* Implementation differs between Keycloak, Auth0, and custom auth.
*/
String extractTenantId(Authentication authentication);
/**
* Extract user roles from the current authentication context.
*/
Set<String> extractRoles(Authentication authentication);
/**
* Extract custom claims relevant to the application.
*/
Map<String, Object> extractClaims(Authentication authentication);
}
// Keycloak implementation
@Component
@Profile("keycloak")
public class KeycloakAuthTokenService implements AuthTokenService {
@Override
public String extractTenantId(Authentication authentication) {
Jwt jwt = ((JwtAuthenticationToken) authentication).getToken();
// Keycloak stores custom attributes in token claims
return jwt.getClaimAsString("tenant_id");
}
@Override
public Set<String> extractRoles(Authentication authentication) {
Jwt jwt = ((JwtAuthenticationToken) authentication).getToken();
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
return new HashSet<>(roles);
}
}
// Spring Authorization Server implementation
@Component
@Profile("custom-auth")
public class CustomAuthTokenService implements AuthTokenService {
@Override
public String extractTenantId(Authentication authentication) {
Jwt jwt = ((JwtAuthenticationToken) authentication).getToken();
return jwt.getClaimAsString("tenant_id");
}
@Override
public Set<String> extractRoles(Authentication authentication) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(a -> a.startsWith("ROLE_"))
.map(a -> a.substring(5))
.collect(Collectors.toSet());
}
}
The Verification
@SpringBootTest
class VendorIndependenceTest {
@Autowired
private AuthTokenService authTokenService;
@Test
void tenantIdExtractedRegardlessOfVendor() {
// Create a JWT that matches the current profile's format
Jwt jwt = Jwt.withTokenValue("test")
.header("alg", "RS256")
.claim("sub", "alice")
.claim("tenant_id", "acme-corp")
.claim("realm_access", Map.of("roles", List.of("user", "admin")))
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt);
// This test passes regardless of whether Keycloak or custom auth is active
assertThat(authTokenService.extractTenantId(auth)).isEqualTo("acme-corp");
}
@Test
void rolesExtractedRegardlessOfVendor() {
Jwt jwt = Jwt.withTokenValue("test")
.header("alg", "RS256")
.claim("sub", "alice")
.claim("realm_access", Map.of("roles", List.of("user", "admin")))
.claim("scope", "project:read project:write")
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
Authentication auth = new JwtAuthenticationToken(jwt,
List.of(new SimpleGrantedAuthority("ROLE_user"),
new SimpleGrantedAuthority("ROLE_admin")));
Set<String> roles = authTokenService.extractRoles(auth);
assertThat(roles).contains("user", "admin");
}
}
The vendor independence layer is the key takeaway regardless of your build/buy decision. If you build, you can migrate to a vendor later without changing application code. If you buy, you can switch vendors without changing application code. The abstraction costs a few hours to implement. The migration it enables saves months.
This is the final chapter. The auth layer is never finished. It is maintained, patched, monitored, and questioned. The chapters in this book are not a checklist to complete. They are a reference to return to when the next CVE drops, when the next penetration test report arrives, and when the next incident escalation asks: “How did they get in?”