JWT Validation
If you're building an API or resource server that receives bearer tokens from clients, you need to validate those JWTs before trusting them. The JwtValidationService handles this: it verifies the cryptographic signature against the identity provider's published keys, checks that the token hasn't expired, confirms the issuer and audience match your expectations, and extracts claims you can use for authorization decisions.
The service supports multiple identity providers, per-tenant IdP routing, OIDC discovery, and JWKS-based signature verification. Use it when you need to validate tokens outside of the OAuth 2.0 client flow, for example, in an API that receives bearer tokens from external clients.
Module Dependencies
Add the JWT validation API and implementation modules to your project:
dependencies {
implementation("com.sphereon.idk:lib-oauth2-jwt-validation-api:0.25.0")
implementation("com.sphereon.idk:lib-oauth2-jwt-validation-impl:0.25.0")
}
For Ktor integration that applies JWT validation automatically to incoming requests, see the EDK documentation:
lib-oauth2-jwt-validation-ktor: Ktor server plugin with automatic token validation
Accessing the Service
JwtValidationService is session-scoped and available from the session graph:
- Android/Kotlin
- iOS/Swift
val jwtValidationService = session.graph.jwtValidationService
let jwtValidationService = session.graph.jwtValidationService
Validating Access Tokens
This is the primary method you'll call on every incoming request to a protected endpoint. It validates the token's signature, checks expiration and issuer, verifies the audience matches your API, and confirms any required scopes or claims are present. If everything passes, you get a ValidatedAccessToken with parsed claims you can use for authorization logic.
The method returns an IdkResult<ValidatedAccessToken, JwtValidationError>, giving you explicit success/failure handling without exceptions.
- Android/Kotlin
- iOS/Swift
val result = jwtValidationService.validateAccessToken(
token = bearerToken,
options = AccessTokenValidationOptions(
expectedAudience = "https://api.example.com",
requiredScopes = setOf("read", "write"),
requiredClaims = listOf("sub", "tenant_id")
)
)
if (result.isOk) {
val token = result.value
// Access standard claims
val subject = token.subject
val issuer = token.issuer
val audiences = token.audiences
val expiresAt = token.expiresAt
val issuedAt = token.issuedAt
val clientId = token.clientId
val tenantId = token.tenantId
// Check scopes
if (token.hasScope("admin")) {
// Grant admin access
}
if (token.hasAllScopes(setOf("read", "write"))) {
// Grant read/write access
}
if (token.hasAnyScope(setOf("admin", "superuser"))) {
// Grant elevated access
}
// Access custom claims
val customClaim = token.claims["custom_field"]
} else {
val error = result.error
handleValidationError(error)
}
let result = try await jwtValidationService.validateAccessToken(
token: bearerToken,
options: AccessTokenValidationOptions(
expectedAudience: "https://api.example.com",
requiredScopes: Set(["read", "write"]),
requiredClaims: ["sub", "tenant_id"]
)
)
if result.isOk {
let token = result.value
// Access standard claims
let subject = token.subject
let issuer = token.issuer
let audiences = token.audiences
let expiresAt = token.expiresAt
let issuedAt = token.issuedAt
let clientId = token.clientId
let tenantId = token.tenantId
// Check scopes
if token.hasScope("admin") {
// Grant admin access
}
if token.hasAllScopes(Set(["read", "write"])) {
// Grant read/write access
}
if token.hasAnyScope(Set(["admin", "superuser"])) {
// Grant elevated access
}
// Access custom claims
let customClaim = token.claims["custom_field"]
} else {
handleValidationError(error: result.error)
}
AccessTokenValidationOptions
| Parameter | Type | Default | Description |
|---|---|---|---|
expectedAudience | String? | null | Expected aud claim value. When set, tokens without a matching audience are rejected. |
idpId | String? | null | ID of a specific IdpConfig to use for validation. When null, the default IdP is used. |
tenantHint | String? | null | Tenant identifier used to route to a tenant-specific IdP configuration. |
requiredClaims | List<String> | [] | Claims that must be present in the token. Validation fails if any are missing. |
requiredScopes | Set<String> | [] | Scopes that the token must contain. Validation fails if any are missing. |
clockSkewSeconds | Long? | null | Override the IdP-level clock skew tolerance for exp and nbf checks. |
ValidatedAccessToken
A successfully validated access token exposes the following properties:
| Property | Type | Description |
|---|---|---|
subject | String | The sub claim identifying the token subject |
issuer | String | The iss claim identifying the token issuer |
audiences | List<String> | The aud claim (always a list, even for single-value audiences) |
expiresAt | Instant | Token expiration time |
issuedAt | Instant | Token issuance time |
notBefore | Instant? | Earliest time the token is valid |
scopes | Set<String> | Scopes granted by the token |
tenantId | String? | Tenant identifier extracted from the configured tenant claim |
clientId | String? | The client_id or azp claim |
jwtId | String? | The jti claim (unique token identifier) |
rawToken | String | The original encoded JWT string |
claims | Map<String, JsonElement> | All token claims as a map |
idpId | String? | The IdP configuration ID that was used for validation |
Scope-checking methods:
hasScope(scope: String): Boolean: Returnstrueif the token contains the given scope.hasAllScopes(scopes: Set<String>): Boolean: Returnstrueif the token contains every scope in the set.hasAnyScope(scopes: Set<String>): Boolean: Returnstrueif the token contains at least one scope from the set.
Validating ID Tokens
ID tokens are different from access tokens: they represent the user's identity rather than API permissions. Use validateIdToken when you receive an OIDC ID token and need to confirm who the user is. On top of the standard signature, expiration, and issuer checks, this method also verifies the nonce (if provided) to prevent replay attacks, and gives you access to standard OIDC identity claims like email, name, and subject.
- Android/Kotlin
- iOS/Swift
val result = jwtValidationService.validateIdToken(
token = idTokenString,
options = IdTokenValidationOptions(
expectedAudience = "my-client-id",
expectedNonce = savedNonce // Verify the nonce from the authorization request
)
)
if (result.isOk) {
val idToken = result.value
// Standard OIDC identity claims
val subject = idToken.subject
val issuer = idToken.issuer
val email = idToken.email
val emailVerified = idToken.emailVerified
val name = idToken.name
val givenName = idToken.givenName
val familyName = idToken.familyName
val preferredUsername = idToken.preferredUsername
// Authentication metadata
val authTime = idToken.authTime
val nonce = idToken.nonce
val audiences = idToken.audiences
val issuedAt = idToken.issuedAt
val expiresAt = idToken.expiresAt
// Tenant context
val tenantId = idToken.tenantId
} else {
val error = result.error
handleValidationError(error)
}
let result = try await jwtValidationService.validateIdToken(
token: idTokenString,
options: IdTokenValidationOptions(
expectedAudience: "my-client-id",
expectedNonce: savedNonce // Verify the nonce from the authorization request
)
)
if result.isOk {
let idToken = result.value
// Standard OIDC identity claims
let subject = idToken.subject
let issuer = idToken.issuer
let email = idToken.email
let emailVerified = idToken.emailVerified
let name = idToken.name
let givenName = idToken.givenName
let familyName = idToken.familyName
let preferredUsername = idToken.preferredUsername
// Authentication metadata
let authTime = idToken.authTime
let nonce = idToken.nonce
let audiences = idToken.audiences
let issuedAt = idToken.issuedAt
let expiresAt = idToken.expiresAt
// Tenant context
let tenantId = idToken.tenantId
} else {
handleValidationError(error: result.error)
}
IdTokenValidationOptions
| Parameter | Type | Default | Description |
|---|---|---|---|
expectedNonce | String? | null | The nonce value sent in the original authorization request. When set, tokens without a matching nonce claim are rejected. |
expectedAudience | String? | null | Expected aud claim value (typically your OAuth 2.0 client ID). |
idpId | String? | null | ID of a specific IdpConfig to use for validation. |
tenantHint | String? | null | Tenant identifier used to route to a tenant-specific IdP configuration. |
Extracting Claims Without Full Validation
Sometimes you need to peek inside a JWT before deciding how to validate it. For example, you might read the iss claim to figure out which IdP configuration to use, or log the sub claim for debugging. extractClaims decodes the token and gives you its header and payload without performing signature verification or expiration checks.
- Android/Kotlin
- iOS/Swift
val result = jwtValidationService.extractClaims(token = rawJwt)
if (result.isOk) {
val claims = result.value
// JWT header
val algorithm = claims.header["alg"]
val keyId = claims.header["kid"]
// Standard payload claims
val issuer = claims.issuer
val subject = claims.subject
val audiences = claims.audiences
val expiresAt = claims.expiresAt
val issuedAt = claims.issuedAt
// Arbitrary payload claims
val tenantId = claims.payload["tenant_id"]
} else {
// Token could not be decoded (malformed JWT)
val error = result.error
}
let result = try await jwtValidationService.extractClaims(token: rawJwt)
if result.isOk {
let claims = result.value
// JWT header
let algorithm = claims.header["alg"]
let keyId = claims.header["kid"]
// Standard payload claims
let issuer = claims.issuer
let subject = claims.subject
let audiences = claims.audiences
let expiresAt = claims.expiresAt
let issuedAt = claims.issuedAt
// Arbitrary payload claims
let tenantId = claims.payload["tenant_id"]
} else {
// Token could not be decoded (malformed JWT)
}
extractClaims does not verify the token signature or check expiration. Never use it as a substitute for validateAccessToken or validateIdToken when making authorization decisions.
Identity Provider Configuration
The JwtValidationService resolves signing keys and validation parameters from IdpConfig objects. Each IdpConfig describes a single identity provider, including its issuer URL, JWKS endpoint, allowed algorithms, and tenant claim mappings. You need at least one IdpConfig for the service to know where to fetch signing keys and what issuer to expect.
IdpConfig Factory Methods
Each identity provider has its own conventions for discovery URLs, token formats, and claim names. The factory methods below handle these differences for you. For example, IdpConfig.keycloak() knows that Keycloak serves its JWKS at a realm-specific URL, while IdpConfig.azureAd() uses Microsoft's v2.0 discovery endpoint. If your provider isn't listed, use IdpConfig.oidc() for any standard OIDC-compliant server or IdpConfig.custom() when you need full control over every parameter.
- Android/Kotlin
- iOS/Swift
// Generic OIDC provider (uses discovery to find JWKS URI)
val oidcIdp = IdpConfig.oidc(
id = "corporate-idp",
issuer = "https://auth.example.com",
audience = "my-api",
discoveryUri = "https://auth.example.com/.well-known/openid-configuration"
)
// Keycloak
val keycloakIdp = IdpConfig.keycloak(
id = "keycloak",
issuer = "https://keycloak.example.com/realms/my-realm",
audience = "my-api"
)
// Azure AD
val azureIdp = IdpConfig.azureAd(
id = "azure",
issuer = "https://login.microsoftonline.com/{tenant-id}/v2.0",
audience = "api://my-api-client-id"
)
// Auth0
val auth0Idp = IdpConfig.auth0(
id = "auth0",
issuer = "https://my-tenant.auth0.com/",
audience = "https://api.example.com"
)
// Okta
val oktaIdp = IdpConfig.okta(
id = "okta",
issuer = "https://my-org.okta.com/oauth2/default",
audience = "api://default"
)
// Custom provider with explicit JWKS URI
val customIdp = IdpConfig.custom(
id = "custom-idp",
issuer = "https://custom-auth.example.com",
jwksUri = "https://custom-auth.example.com/keys",
audience = "my-api",
allowedAlgorithms = listOf("RS256", "ES256", "PS256"),
clockSkewSeconds = 30,
tenantClaim = "org_id",
tenantClaimAlternatives = listOf("tenant_id", "tid"),
requiredClaims = listOf("sub", "org_id")
)
// Generic OIDC provider (uses discovery to find JWKS URI)
let oidcIdp = IdpConfig.oidc(
id: "corporate-idp",
issuer: "https://auth.example.com",
audience: "my-api",
discoveryUri: "https://auth.example.com/.well-known/openid-configuration"
)
// Keycloak
let keycloakIdp = IdpConfig.keycloak(
id: "keycloak",
issuer: "https://keycloak.example.com/realms/my-realm",
audience: "my-api"
)
// Azure AD
let azureIdp = IdpConfig.azureAd(
id: "azure",
issuer: "https://login.microsoftonline.com/{tenant-id}/v2.0",
audience: "api://my-api-client-id"
)
// Auth0
let auth0Idp = IdpConfig.auth0(
id: "auth0",
issuer: "https://my-tenant.auth0.com/",
audience: "https://api.example.com"
)
// Okta
let oktaIdp = IdpConfig.okta(
id: "okta",
issuer: "https://my-org.okta.com/oauth2/default",
audience: "api://default"
)
// Custom provider with explicit JWKS URI
let customIdp = IdpConfig.custom(
id: "custom-idp",
issuer: "https://custom-auth.example.com",
jwksUri: "https://custom-auth.example.com/keys",
audience: "my-api",
allowedAlgorithms: ["RS256", "ES256", "PS256"],
clockSkewSeconds: 30,
tenantClaim: "org_id",
tenantClaimAlternatives: ["tenant_id", "tid"],
requiredClaims: ["sub", "org_id"]
)
IdpConfig Properties
| Property | Type | Default | Description |
|---|---|---|---|
id | String | -- | Unique identifier for this IdP configuration |
type | IdpType | -- | Provider type: OIDC, KEYCLOAK, AZURE_AD, AUTH0, OKTA, or CUSTOM |
issuer | String | -- | Expected issuer URL (must match the iss claim) |
audience | String? | null | Expected audience (overridable per-request via validation options) |
jwksUri | String? | null | Explicit JWKS endpoint URL. When null, discovered via OIDC discovery. |
discoveryUri | String? | null | OIDC discovery document URL. Defaults to {issuer}/.well-known/openid-configuration. |
tenantClaim | String | "tenant_id" | JWT claim name used to extract the tenant identifier |
tenantClaimAlternatives | List<String> | [] | Fallback claim names if the primary tenant claim is absent |
allowedAlgorithms | List<String> | ["RS256", "ES256"] | Signature algorithms accepted for this IdP |
clockSkewSeconds | Long | 60 | Tolerance in seconds for exp and nbf checks |
jwksCacheTtlSeconds | Long | 3600 | How long to cache the JWKS response before refreshing |
requiredClaims | List<String> | [] | Claims that must be present for tokens from this IdP |
JwtValidationConfig
Once you have your IdpConfig objects, you combine them into a JwtValidationConfig. This is where you set a default IdP for most requests, map specific tenants to their own IdPs, and optionally allow unauthenticated access to certain paths (like health checks).
- Android/Kotlin
- iOS/Swift
val config = JwtValidationConfig(
enabled = true,
defaultIdp = oidcIdp,
tenantIdps = mapOf(
"tenant-a" to keycloakIdp,
"tenant-b" to azureIdp
),
anonymous = AnonymousAccessConfig(
allowed = true,
allowedPaths = listOf("/health", "/public/*")
)
)
let config = JwtValidationConfig(
enabled: true,
defaultIdp: oidcIdp,
tenantIdps: [
"tenant-a": keycloakIdp,
"tenant-b": azureIdp
],
anonymous: AnonymousAccessConfig(
allowed: true,
allowedPaths: ["/health", "/public/*"]
)
)
Multi-Tenant Validation
In multi-tenant applications, different tenants may authenticate through different identity providers. For example, Tenant A might use Keycloak while Tenant B uses Azure AD, each with its own issuer, signing keys, and token format. The tenantHint parameter in the validation options routes the token to the correct IdpConfig so the right keys and issuer are used for verification.
- Android/Kotlin
- iOS/Swift
// The tenant hint selects the IdP from JwtValidationConfig.tenantIdps
val result = jwtValidationService.validateAccessToken(
token = bearerToken,
options = AccessTokenValidationOptions(
tenantHint = "tenant-a" // Routes to the Keycloak IdP
)
)
result.onSuccess { token ->
// tenantId is extracted from the claim configured in IdpConfig.tenantClaim
val tenantId = token.tenantId
println("Validated token for tenant: $tenantId")
}
// The tenant hint selects the IdP from JwtValidationConfig.tenantIdps
let result = try await jwtValidationService.validateAccessToken(
token: bearerToken,
options: AccessTokenValidationOptions(
tenantHint: "tenant-a" // Routes to the Keycloak IdP
)
)
if result.isOk {
// tenantId is extracted from the claim configured in IdpConfig.tenantClaim
let tenantId = result.value.tenantId
print("Validated token for tenant: \(tenantId ?? "unknown")")
}
The resolution order is:
- If
idpIdis set in the validation options, use that specific IdP configuration. - If
tenantHintis set, look up the corresponding entry inJwtValidationConfig.tenantIdps. - Fall back to
JwtValidationConfig.defaultIdp.
You can extract the tenant hint from the incoming request (e.g., from a subdomain, HTTP header, or path segment) before calling validateAccessToken. This allows fully dynamic IdP routing without hard-coding tenant-to-IdP mappings in your request handlers.
Validation Errors
When validation fails, you get a structured error rather than a generic exception. The JwtValidationError contains a type field that tells you exactly what went wrong (expired token, bad signature, unknown issuer, etc.), a human-readable message, and optional contextual properties like the issuer or claim name involved. This makes it straightforward to return the right HTTP status code and error message to the caller.
Error Types
| Error Type | Description |
|---|---|
MISSING_TOKEN | No token was provided |
INVALID_TOKEN_FORMAT | The token is not a valid JWT (wrong number of segments, invalid Base64) |
SIGNATURE_INVALID | The cryptographic signature does not match |
TOKEN_EXPIRED | The token's exp claim is in the past (accounting for clock skew) |
TOKEN_NOT_YET_VALID | The token's nbf claim is in the future (accounting for clock skew) |
UNTRUSTED_ISSUER | The token's iss claim does not match any configured issuer |
INVALID_AUDIENCE | The token's aud claim does not match the expected audience |
MISSING_REQUIRED_CLAIM | A claim listed in requiredClaims is absent from the token |
JWKS_UNAVAILABLE | The JWKS endpoint could not be reached or returned an error |
KEY_NOT_FOUND | No key matching the token's kid header was found in the JWKS |
ALGORITHM_NOT_ALLOWED | The token's signing algorithm is not in the IdP's allowedAlgorithms |
IDP_CONFIGURATION_ERROR | The IdP configuration is invalid or incomplete |
DISCOVERY_FAILED | OIDC discovery document could not be fetched or parsed |
VALIDATION_ERROR | A general validation error not covered by the other types |
Error Properties
| Property | Type | Description |
|---|---|---|
type | JwtValidationErrorType | The error category from the enum above |
message | String | Human-readable description of the failure |
issuer | String? | The token's issuer, if available |
audience | String? | The relevant audience value |
claim | String? | The claim name involved (for MISSING_REQUIRED_CLAIM) |
cause | Throwable? | The underlying exception, if any |
Handling Errors
In practice, you'll want to map each error type to an appropriate HTTP response. Expired tokens and bad signatures should return 401 (Unauthorized), audience or scope mismatches typically map to 403 (Forbidden), and infrastructure issues like an unreachable JWKS endpoint warrant a 503 (Service Unavailable).
- Android/Kotlin
- iOS/Swift
val result = jwtValidationService.validateAccessToken(token = bearerToken)
if (result.isOk) {
proceedWithRequest(result.value)
} else {
val error = result.error
when (error.type) {
JwtValidationErrorType.TOKEN_EXPIRED ->
respondUnauthorized("Token expired. Please refresh your access token.")
JwtValidationErrorType.SIGNATURE_INVALID ->
respondUnauthorized("Invalid token signature.")
JwtValidationErrorType.INVALID_AUDIENCE ->
respondForbidden("Token not intended for this API.")
JwtValidationErrorType.MISSING_REQUIRED_CLAIM ->
respondForbidden("Missing required claim: ${error.claim}")
JwtValidationErrorType.JWKS_UNAVAILABLE,
JwtValidationErrorType.DISCOVERY_FAILED ->
respondServiceUnavailable("Identity provider temporarily unavailable.")
else ->
respondUnauthorized("Token validation failed: ${error.message}")
}
}
let result = try await jwtValidationService.validateAccessToken(token: bearerToken)
if result.isOk {
proceedWithRequest(token: result.value)
} else {
let error = result.error
switch error.type {
case .tokenExpired:
respondUnauthorized(message: "Token expired. Please refresh your access token.")
case .signatureInvalid:
respondUnauthorized(message: "Invalid token signature.")
case .invalidAudience:
respondForbidden(message: "Token not intended for this API.")
case .missingRequiredClaim:
respondForbidden(message: "Missing required claim: \(error.claim ?? "unknown")")
case .jwksUnavailable, .discoveryFailed:
respondServiceUnavailable(message: "Identity provider temporarily unavailable.")
default:
respondUnauthorized(message: "Token validation failed: \(error.message)")
}
}
Factory Methods
If you're building custom validation logic on top of the service (for example, checking a claim value that isn't covered by the built-in options), you can use these factory methods to produce error objects that are consistent with the ones the service itself returns.
JwtValidationError.missingToken()
JwtValidationError.invalidFormat(details = "Expected 3 JWT segments, found 2")
JwtValidationError.signatureInvalid(issuer = "https://auth.example.com")
JwtValidationError.expired(expiresAt = token.expiresAt)
JwtValidationError.untrustedIssuer(
issuer = "https://unknown.example.com",
trustedIssuers = listOf("https://auth.example.com")
)
JwtValidationError.invalidAudience(
tokenAudience = "other-api",
expectedAudience = "my-api"
)
JwtValidationError.missingClaim(claim = "tenant_id")
JwtValidationError.jwksUnavailable(
issuer = "https://auth.example.com",
cause = connectException
)
JwtValidationError.keyNotFound(kid = "key-2024", issuer = "https://auth.example.com")
JwtValidationError.algorithmNotAllowed(
algorithm = "HS256",
allowedAlgorithms = listOf("RS256", "ES256")
)
Configuration via Properties
As an alternative to building IdpConfig and JwtValidationConfig objects in code, you can declare everything in properties. This is particularly convenient for deployment environments where you want to change IdP settings without recompiling.
# Enable JWT validation
jwt.validation.enabled=true
# Default identity provider
jwt.validation.default.issuer=https://auth.example.com
jwt.validation.default.audience=my-api
jwt.validation.default.type=OIDC
jwt.validation.default.allowed-algorithms=RS256,ES256
jwt.validation.default.clock-skew-seconds=60
jwt.validation.default.jwks-cache-ttl-seconds=3600
jwt.validation.default.required-claims=sub
jwt.validation.default.tenant-claim=tenant_id
# Tenant-specific IdP overrides
jwt.validation.tenants.tenant-a.issuer=https://keycloak.example.com/realms/tenant-a
jwt.validation.tenants.tenant-a.type=KEYCLOAK
jwt.validation.tenants.tenant-a.audience=my-api
jwt.validation.tenants.tenant-b.issuer=https://login.microsoftonline.com/{tenant-b-id}/v2.0
jwt.validation.tenants.tenant-b.type=AZURE_AD
jwt.validation.tenants.tenant-b.audience=api://my-api-client-id
# Anonymous access
jwt.validation.anonymous.allowed=true
jwt.validation.anonymous.allowed-paths=/health,/public/*
Best Practices
Always validate tokens on the server side. Never trust a JWT solely because the client presents it. Use validateAccessToken or validateIdToken with appropriate options for every protected endpoint.
Set an expected audience. Audience validation prevents tokens issued for a different API from being accepted by yours. Always set expectedAudience to your API's identifier.
Require the scopes you need. Use requiredScopes in AccessTokenValidationOptions to enforce that the token carries the permissions your endpoint requires. Check fine-grained scopes with hasScope, hasAllScopes, or hasAnyScope on the validated token.
Verify the nonce for ID tokens. When validating ID tokens from an authorization flow you initiated, always pass the expectedNonce to prevent token replay attacks.
Configure appropriate clock skew. Distributed systems have clock drift. The default of 60 seconds is a reasonable starting point. Reduce it if your infrastructure has tight time synchronization; increase it if you see spurious TOKEN_EXPIRED or TOKEN_NOT_YET_VALID errors.
Handle JWKS and discovery failures gracefully. The JWKS and OIDC discovery endpoints are cached (jwksCacheTtlSeconds), so transient network issues do not immediately affect validation. Monitor for JWKS_UNAVAILABLE and DISCOVERY_FAILED errors to detect persistent connectivity problems with your identity provider.
Use extractClaims only for inspection. The extractClaims method does not verify signatures. Use it for routing or logging, never for authorization decisions.
Log validation failures. Monitoring patterns in validation errors (e.g., a spike in SIGNATURE_INVALID or UNTRUSTED_ISSUER) can indicate misconfiguration or attempted attacks.