JWT Validation
The JWT validation framework validates access tokens from multiple identity providers simultaneously, with automatic OIDC discovery, per-tenant IdP routing, JWKS caching, and scope enforcement. The core validation logic lives in the IDK (lib-authn-jwt-validation), with Ktor and Spring Boot integrations in the EDK.
Key Capabilities
Multiple IdPs at once. A single service can accept tokens from Keycloak, Azure AD, Auth0, Okta, and any generic OIDC provider, all at the same time. The validator extracts the issuer from the unverified token, routes to the correct IdP configuration, and validates against that provider's JWKS.
Per-tenant IdP routing. Different tenants can use different identity providers. Tenant A might authenticate via Azure AD while Tenant B uses Auth0. The framework resolves the correct IdP based on the token's issuer claim and maps it to the tenant configuration.
Automatic OIDC discovery. For any OIDC-compliant provider, you only need the issuer URL. The framework fetches the .well-known/openid-configuration, discovers the JWKS URI, and caches the signing keys. For non-standard providers, you can supply an explicit JWKS URI.
JWKS caching with rotation support. Public keys are cached (default 1 hour TTL) and refreshed automatically. When a token presents an unknown kid, the framework re-fetches the JWKS before rejecting, this handles key rotation gracefully without downtime.
Scope and claim enforcement. Built-in helpers for checking OAuth2 scopes (hasScope, hasAllScopes, hasAnyScope) and requiring specific claims. Scope checks can guard individual endpoints or entire route groups.
Architecture
The validation flow:
- Extract the bearer token from the
Authorizationheader - Decode the JWT header (without verification) to read the
issandkidclaims - Route to the matching
IdpConfigbased on issuer - Fetch or retrieve cached JWKS for that IdP
- Verify signature, expiration, not-before, audience, and required claims
- Return a
ValidatedAccessTokenwith all extracted claims and metadata
Supported Identity Providers
| Type | Discovery | Notes |
|---|---|---|
OIDC | Automatic via /.well-known/openid-configuration | Any OIDC-compliant provider |
KEYCLOAK | Automatic with realm-aware URLs | Constructs issuer and JWKS from base URL + realm |
AZURE_AD | Automatic via Azure AD v2.0 metadata | Supports single-tenant and multi-tenant configurations |
AUTH0 | Automatic via Auth0 domain | Domain-based discovery |
OKTA | Automatic via Okta authorization server | Supports custom authorization servers |
CUSTOM | Explicit JWKS URI required | For legacy or non-standard providers |
Configuration
Programmatic (Ktor / IDK)
// Keycloak
val keycloak = IdpConfig.keycloak(
id = "keycloak-prod",
baseUrl = "https://auth.example.com",
realm = "master",
audience = "my-api"
)
// Azure AD / Entra ID
val azureAd = IdpConfig.azureAd(
id = "azure-prod",
tenantId = "your-azure-tenant-id",
audience = "api://my-api"
)
// Auth0
val auth0 = IdpConfig.auth0(
id = "auth0-prod",
domain = "your-tenant.auth0.com",
audience = "https://api.example.com"
)
// Generic OIDC
val oidc = IdpConfig.oidc(
id = "custom-idp",
issuer = "https://idp.example.com",
audience = "my-api"
)
// Custom with explicit JWKS URI (non-OIDC providers)
val custom = IdpConfig.custom(
id = "legacy-idp",
issuer = "https://legacy.example.com",
jwksUri = "https://legacy.example.com/.well-known/jwks.json",
audience = "my-api"
)
YAML (Spring Boot)
sphereon:
auth:
jwt:
enabled: true
default-idp:
issuer: https://auth.example.com/realms/master
audience: my-api
tenant-claim: tenant_id
tenant-idps:
tenant-a:
issuer: https://login.microsoftonline.com/tenant-a-id/v2.0
type: AZURE_AD
tenant-claim: tid
tenant-b:
issuer: https://tenant-b.auth0.com/
type: AUTH0
audience: api://tenant-b
anonymous:
allowed: false
allowed-paths:
- /health
- /actuator/**
- /.well-known/**
IdpConfig Properties
| Property | Default | Description |
|---|---|---|
id | - | Unique identifier for this IdP configuration |
type | OIDC | Provider type (affects discovery URL construction) |
issuer | - | Expected iss claim value |
audience | - | Expected aud claim value. Always configure this to prevent token confusion attacks. |
jwksUri | - | Explicit JWKS URI. Only needed for CUSTOM type; others use discovery. |
discoveryUri | - | Override for OIDC discovery URL (auto-derived from issuer if not set) |
tenantClaim | tenant_id | Claim name to extract tenant ID from |
tenantClaimAlternatives | [azp, client_id] | Fallback claims for tenant ID extraction |
allowedAlgorithms | [RS256, ES256] | Accepted signing algorithms. Restrict to prevent algorithm confusion. |
clockSkewSeconds | 60 | Tolerance for clock drift on expiration/not-before checks |
jwksCacheTtlSeconds | 3600 | How long to cache the JWKS response |
requiredClaims | [] | Claims that must be present for the token to be accepted |
Validated Token
Successful validation returns a ValidatedAccessToken containing all extracted information:
data class ValidatedAccessToken(
val subject: String,
val issuer: String,
val audiences: List<String>,
val expiresAt: Long,
val issuedAt: Long,
val notBefore: Long?,
val scopes: Set<String>,
val tenantId: String?,
val clientId: String?,
val jwtId: String?,
val rawToken: String,
val claims: Map<String, JsonElement>,
val idpId: String,
)
Scope checking helpers:
token.hasScope("admin") // Single scope
token.hasAllScopes(setOf("read", "write")) // All required
token.hasAnyScope(setOf("admin", "super")) // At least one
Per-Tenant IdP Routing
In a multi-tenant deployment, each tenant can authenticate through a different identity provider. The framework routes incoming tokens to the correct IdP configuration based on the token's issuer:
Tenant A → Azure AD → validate against Azure AD JWKS
Tenant B → Auth0 → validate against Auth0 JWKS
Tenant C → Keycloak → validate against Keycloak JWKS
Unknown → default IdP → validate against default JWKS
The MultiTenantJwtDecoder (Spring) and JwtAuthentication plugin (Ktor) handle this routing automatically. You can also register and remove IdP configurations at runtime, useful for onboarding new tenants without restarting the service.
Ktor Integration
Plugin Installation
fun Application.module() {
install(JwtAuthentication) {
validationService = myJwtValidationService
anonymousConfig = AnonymousAccessConfig(
allowed = false,
allowedPaths = listOf("/health", "/api/public/*")
)
onAuthenticationFailed = { call, message ->
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to message))
}
}
}
Accessing the Token in Routes
routing {
get("/protected") {
val token = call.attributes[ValidatedTokenKey]
call.respond("Hello ${token.subject}, tenant: ${token.tenantId}")
}
get("/admin") {
val token = call.attributes[ValidatedTokenKey]
if (!token.hasScope("admin")) {
call.respond(HttpStatusCode.Forbidden, "Requires admin scope")
return@get
}
call.respond("Admin access granted")
}
}
Tenant and Principal Resolution
The Ktor plugin integrates with the EDK's user context system, so tenant and principal are automatically resolved from the validated token:
fun Application.module() {
install(JwtAuthentication) { ... }
install(KotlinInjectPlugin) {
appComponent = myAppComponent
tenantResolver = ValidatingTenantResolver(defaultTenantId = "default")
principalResolver = ValidatingPrincipalResolver()
}
}
This means downstream code can use the standard SessionContext, it doesn't need to know that the tenant and principal were extracted from a JWT.
Spring Boot Integration
Auto-Configuration
With the sphereon.auth.jwt properties configured, Spring Boot auto-configuration sets up:
- A
JwtValidationServicebean with all registered IdPs - A
MultiTenantJwtDecoderthat routes tokens to the correct IdP - A
ValidatingAuthenticationResolverfor use in controllers - Anonymous path exclusions
Controller Usage
@RestController
@RequestMapping("/api")
class MyController(
private val authResolver: ValidatingAuthenticationResolver
) {
@GetMapping("/profile")
fun getProfile(request: HttpServletRequest): Map<String, Any> {
val token = authResolver.requireValidatedToken(request)
return mapOf(
"subject" to token.subject,
"tenant" to (token.tenantId ?: "unknown"),
"scopes" to token.scopes
)
}
@PostMapping("/admin/action")
fun adminAction(request: HttpServletRequest): ResponseEntity<Any> {
if (!authResolver.hasScope(request, "admin")) {
return ResponseEntity.status(403).body(mapOf("error" to "Requires admin scope"))
}
return ResponseEntity.ok(mapOf("result" to "success"))
}
}
Token Forwarding
Forward the validated token to downstream services for service-to-service authentication:
@Service
class DownstreamClient(
private val authResolver: ValidatingAuthenticationResolver,
private val restTemplate: RestTemplate
) {
fun callDownstream(request: HttpServletRequest): String {
val rawToken = authResolver.getRawToken(request)
?: throw UnauthorizedException("No token")
val headers = HttpHeaders().apply { setBearerAuth(rawToken) }
return restTemplate.exchange(
"http://downstream/api/resource",
HttpMethod.GET,
HttpEntity<Void>(headers),
String::class.java
).body ?: ""
}
}
Error Handling
Validation failures return a typed JwtValidationError with the specific reason:
| Error Type | Meaning |
|---|---|
MISSING_TOKEN | No bearer token in Authorization header |
INVALID_TOKEN_FORMAT | Token is not a valid JWT |
SIGNATURE_INVALID | Signature doesn't match any key in the JWKS |
TOKEN_EXPIRED | Token's exp claim is in the past (beyond clock skew) |
TOKEN_NOT_YET_VALID | Token's nbf claim is in the future |
UNTRUSTED_ISSUER | Issuer doesn't match any configured IdP |
INVALID_AUDIENCE | Audience doesn't match the expected value |
MISSING_REQUIRED_CLAIM | A claim listed in requiredClaims is missing |
JWKS_UNAVAILABLE | Could not fetch the JWKS from the IdP |
KEY_NOT_FOUND | Token's kid not found in JWKS (after re-fetch) |
ALGORITHM_NOT_ALLOWED | Token signed with an algorithm not in allowedAlgorithms |
DISCOVERY_FAILED | OIDC discovery endpoint unreachable or invalid |
Integration with AuthZEN
JWT validation establishes who the caller is. For fine-grained authorization (what the caller can do), forward the validated token to the AuthZEN policy engine:
val token = authResolver.requireValidatedToken(request)
val decision = policyEngine.evaluate(PolicyRequest(
principal = PolicyPrincipal(
type = "User",
id = token.subject,
attributes = mapOf("scopes" to JsonArray(token.scopes.map { JsonPrimitive(it) }))
),
action = PolicyAction(name = "resource.read"),
resource = PolicyResource(type = "Document", id = resourceId),
context = PolicyContext(tenantId = token.tenantId)
))
if (decision.isDenied) throw ForbiddenException("Access denied")
JWT validation answers "who is this?", AuthZEN answers "are they allowed to do this?"
Best Practices
Always validate audience. Configure the expected audience on every IdP to prevent token confusion attacks where a token issued for a different service is replayed against yours.
Restrict signing algorithms. Only allow the algorithms your IdPs actually use (typically RS256 and ES256). This prevents algorithm confusion attacks.
Keep clock skew minimal. 30–60 seconds is sufficient. Larger values weaken expiration enforcement.
Use OIDC discovery when possible. It handles JWKS URI resolution, key rotation, and issuer validation automatically. Only use CUSTOM type for providers that don't support discovery.
Validate required claims. Use requiredClaims for claims your application depends on (e.g., tenant_id) so missing claims fail fast at the validation layer rather than causing null errors downstream.