Skip to main content
Version: v0.25.0 (Latest)

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:

build.gradle.kts
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")
}
EDK Framework Integration

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:

val 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.

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)
}

AccessTokenValidationOptions

ParameterTypeDefaultDescription
expectedAudienceString?nullExpected aud claim value. When set, tokens without a matching audience are rejected.
idpIdString?nullID of a specific IdpConfig to use for validation. When null, the default IdP is used.
tenantHintString?nullTenant identifier used to route to a tenant-specific IdP configuration.
requiredClaimsList<String>[]Claims that must be present in the token. Validation fails if any are missing.
requiredScopesSet<String>[]Scopes that the token must contain. Validation fails if any are missing.
clockSkewSecondsLong?nullOverride the IdP-level clock skew tolerance for exp and nbf checks.

ValidatedAccessToken

A successfully validated access token exposes the following properties:

PropertyTypeDescription
subjectStringThe sub claim identifying the token subject
issuerStringThe iss claim identifying the token issuer
audiencesList<String>The aud claim (always a list, even for single-value audiences)
expiresAtInstantToken expiration time
issuedAtInstantToken issuance time
notBeforeInstant?Earliest time the token is valid
scopesSet<String>Scopes granted by the token
tenantIdString?Tenant identifier extracted from the configured tenant claim
clientIdString?The client_id or azp claim
jwtIdString?The jti claim (unique token identifier)
rawTokenStringThe original encoded JWT string
claimsMap<String, JsonElement>All token claims as a map
idpIdString?The IdP configuration ID that was used for validation

Scope-checking methods:

  • hasScope(scope: String): Boolean: Returns true if the token contains the given scope.
  • hasAllScopes(scopes: Set<String>): Boolean: Returns true if the token contains every scope in the set.
  • hasAnyScope(scopes: Set<String>): Boolean: Returns true if 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.

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)
}

IdTokenValidationOptions

ParameterTypeDefaultDescription
expectedNonceString?nullThe nonce value sent in the original authorization request. When set, tokens without a matching nonce claim are rejected.
expectedAudienceString?nullExpected aud claim value (typically your OAuth 2.0 client ID).
idpIdString?nullID of a specific IdpConfig to use for validation.
tenantHintString?nullTenant 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.

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
}
warning

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.

// 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")
)

IdpConfig Properties

PropertyTypeDefaultDescription
idString--Unique identifier for this IdP configuration
typeIdpType--Provider type: OIDC, KEYCLOAK, AZURE_AD, AUTH0, OKTA, or CUSTOM
issuerString--Expected issuer URL (must match the iss claim)
audienceString?nullExpected audience (overridable per-request via validation options)
jwksUriString?nullExplicit JWKS endpoint URL. When null, discovered via OIDC discovery.
discoveryUriString?nullOIDC discovery document URL. Defaults to {issuer}/.well-known/openid-configuration.
tenantClaimString"tenant_id"JWT claim name used to extract the tenant identifier
tenantClaimAlternativesList<String>[]Fallback claim names if the primary tenant claim is absent
allowedAlgorithmsList<String>["RS256", "ES256"]Signature algorithms accepted for this IdP
clockSkewSecondsLong60Tolerance in seconds for exp and nbf checks
jwksCacheTtlSecondsLong3600How long to cache the JWKS response before refreshing
requiredClaimsList<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).

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/*")
)
)

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.

// 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 resolution order is:

  1. If idpId is set in the validation options, use that specific IdP configuration.
  2. If tenantHint is set, look up the corresponding entry in JwtValidationConfig.tenantIdps.
  3. Fall back to JwtValidationConfig.defaultIdp.
tip

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 TypeDescription
MISSING_TOKENNo token was provided
INVALID_TOKEN_FORMATThe token is not a valid JWT (wrong number of segments, invalid Base64)
SIGNATURE_INVALIDThe cryptographic signature does not match
TOKEN_EXPIREDThe token's exp claim is in the past (accounting for clock skew)
TOKEN_NOT_YET_VALIDThe token's nbf claim is in the future (accounting for clock skew)
UNTRUSTED_ISSUERThe token's iss claim does not match any configured issuer
INVALID_AUDIENCEThe token's aud claim does not match the expected audience
MISSING_REQUIRED_CLAIMA claim listed in requiredClaims is absent from the token
JWKS_UNAVAILABLEThe JWKS endpoint could not be reached or returned an error
KEY_NOT_FOUNDNo key matching the token's kid header was found in the JWKS
ALGORITHM_NOT_ALLOWEDThe token's signing algorithm is not in the IdP's allowedAlgorithms
IDP_CONFIGURATION_ERRORThe IdP configuration is invalid or incomplete
DISCOVERY_FAILEDOIDC discovery document could not be fetched or parsed
VALIDATION_ERRORA general validation error not covered by the other types

Error Properties

PropertyTypeDescription
typeJwtValidationErrorTypeThe error category from the enum above
messageStringHuman-readable description of the failure
issuerString?The token's issuer, if available
audienceString?The relevant audience value
claimString?The claim name involved (for MISSING_REQUIRED_CLAIM)
causeThrowable?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).

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}")
}
}

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.