Skip to main content
the auth layer

The Total Cost of Rolling Your Own and the Migration Trap

6 min read Chapter 45 of 45

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:

  1. Export all users (including hashed passwords, which may use Keycloak-specific hashing)
  2. Recreate all client registrations with new redirect URIs
  3. Reconfigure all SAML federations (new entity IDs, new metadata, new certificates)
  4. Update every service’s JWT validation to accept the new issuer
  5. Coordinate a cutover window where both systems run simultaneously
  6. 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

CategoryAnnual cost (engineer-hours)Frequency
Spring Security major version upgrade40-80Annual
Security dependency patches (CVEs)20-404-8 per year
Key rotation operations10-20Quarterly
Penetration test remediation40-80Annual
On-call incidents (auth-related)20-60Continuous
Feature requests (new grant types, new federation partners)40-120Per request
Total170-400

At $150/hour fully loaded, annual maintenance: $25,000-$60,000.

Ongoing Cost Model: Buy

CategoryAnnual costFrequency
Vendor subscription (per-MAU pricing)VariesMonthly
Integration maintenance (vendor API changes)20-40 hoursPer change
Custom claim/flow updates10-30 hoursPer request
Vendor incident response (their outage = your outage)0-40 hoursPer incident
Migration risk reserveIntangibleContinuous
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?”