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

OID4VP Verifier

The OID4VP verifier component enables relying party applications to request and verify credential presentations. This guide covers creating authorization requests, processing responses, and validating presented credentials.

Obtaining the Verifier Service

The verifier service (also called the relying party, or RP) is responsible for creating authorization requests that ask holders for credentials, and then validating the presentations that come back. You use a single Oid4vpRpService instance throughout your application.

val rp: Oid4vpRpService = session.graph.oid4vpRpService

Creating Authorization Requests

An authorization request describes which credentials you need and where the holder should send them. You define the credential requirements using a DCQL (Digital Credentials Query Language) query, specifying the format, document type, and individual claims you want disclosed. The clientId identifies your verifier, and responseUri tells the holder's wallet where to POST the presentation. The nonce is a one-time value you generate and store so you can later verify the response was not replayed.

// Build the DCQL query
val dcqlQuery = DcqlQuery(
credentials = listOf(
DcqlCredentialQuery(
id = "identity_credential",
format = "mso_mdoc",
meta = buildJsonObject {
put("doctype_value", "org.iso.18013.5.1.mDL")
},
claims = listOf(
DcqlClaimQuery(path = listOf("org.iso.18013.5.1", "family_name")),
DcqlClaimQuery(path = listOf("org.iso.18013.5.1", "given_name")),
DcqlClaimQuery(path = listOf("org.iso.18013.5.1", "birth_date"))
)
)
)
)

// Create the authorization request
val createResult = rp.createAuthorizationRequest(
CreateAuthorizationRequestArgs(
dcqlQuery = dcqlQuery,
clientId = "https://verifier.example.com",
responseUri = "https://verifier.example.com/callback",
responseMode = ResponseMode.DIRECT_POST,
nonce = generateSecureNonce(), // Minimum 8 characters
state = generateSessionId(),
clientIdScheme = ClientIdScheme.REDIRECT_URI
)
)

if (createResult.isOk) {
val created = createResult.value
val authRequest = created.request
val sessionId = created.sessionId // For correlating responses
}

Building the Request URI

Once you have an authorization request object, you need to encode it as a URI that a wallet can open. In a cross-device flow, you display this URI as a QR code that the user scans with their phone. In a same-device flow, you open it directly as a deep link so the wallet app launches on the same device. The scheme (openid4vp://) tells the OS to route the URI to a compatible wallet.

val uriResult = rp.buildAuthorizationRequestUri(
BuildAuthorizationRequestUriArgs(
request = authRequest,
scheme = Oid4vpUriScheme.OPENID4VP // openid4vp://
)
)

if (uriResult.isOk) {
val requestUri = uriResult.value
// requestUri: openid4vp://authorize?client_id=...&response_uri=...&dcql_query=...

// Generate QR code for cross-device flow
val qrBitmap = QRCodeGenerator.generate(requestUri)

// Or use as deep link for same-device flow
openWallet(requestUri)
}

Signed Request Objects

An unsigned request is fine when you trust the transport, but signed requests (JWT-Secured Authorization Requests, or JAR) let the holder verify the request actually came from your verifier. You need signing when using x509_san_dns or x509_san_uri client ID schemes, or any time the request travels through a channel where it could be tampered with. The signed request wraps the same parameters into a JWT signed with your verifier's key.

val signedResult = rp.createSignedAuthorizationRequest(
CreateSignedAuthorizationRequestArgs(
request = authRequest,
signingKey = verifierKeyInfo,
clientIdScheme = ClientIdScheme.X509_SAN_DNS
)
)

if (signedResult.isOk) {
val signedRequest = signedResult.value
// signedRequest.signedJar contains the signed request object (JAR)

// Build URI with signed request
val uriResult = rp.buildAuthorizationRequestUri(
BuildAuthorizationRequestUriArgs(
request = signedRequest,
scheme = Oid4vpUriScheme.OPENID4VP
)
)
}

Parsing Authorization Responses

After the holder's wallet processes your request, it sends back an authorization response containing a vp_token (the verifiable presentation) and the state value you provided earlier. With direct_post response mode, the wallet POSTs these parameters to your responseUri endpoint. Parsing extracts the token and correlates it with your original request using the state.

// For direct_post response mode - handle POST to your callback endpoint
fun handleCallback(request: HttpRequest): HttpResponse {
val responseParams = mapOf(
"vp_token" to request.formParam("vp_token"),
"state" to request.formParam("state")
)

// Parse the response
val parseResult = rp.parseAuthorizationResponse(
ParseAuthorizationResponseArgs(
responseParams = responseParams,
originalRequest = authRequest // The original request for correlation
)
)

if (parseResult.isOk) {
val parsed = parseResult.value
val vpToken = parsed.vpToken
val state = parsed.state
}
}

Validating Authorization Responses

Validation checks that the response actually satisfies your original DCQL query: the correct credential formats were used, the required claims are present, and the nonce matches what you sent. If validation succeeds, you get a list of matched credentials with their disclosed claims.

val validateResult = rp.validateAuthorizationResponse(
ValidateAuthorizationResponseArgs(
parsedResponse = parsed,
originalRequest = authRequest,
dcqlQuery = dcqlQuery,
expectedNonce = originalNonce
)
)

if (validateResult.isOk) {
val validation = validateResult.value

if (validation.valid) {
// Access matched credentials
validation.matchedCredentials.forEach { matched ->
println("Credential: ${matched.credentialQueryId}")
println("Format: ${matched.format}")

// Access disclosed claims
matched.disclosedClaims.forEach { (key, value) ->
println(" $key: $value")
}
}
} else {
// Handle validation errors
validation.errors.forEach { error ->
println("Validation error: $error")
}
}
}

Verifying Holder Binding

Holder binding proves that the person presenting the credential is the same person it was issued to. The check verifies a cryptographic signature made by the holder's private key, confirms the nonce matches (preventing replay), and validates the audience matches your verifier's client ID. For SD-JWT credentials, it also verifies the sd_hash linking the key binding JWT to the specific selective disclosure presentation.

// For each matched credential, verify holder binding
for (matched in validation.matchedCredentials) {
val bindingResult = rp.verifyHolderBinding(
VerifyHolderBindingArgs(
presentation = matched.presentation,
format = matched.format,
expectedNonce = originalNonce,
expectedAudience = "https://verifier.example.com"
)
)

if (bindingResult.isOk) {
val binding = bindingResult.value

if (binding.verified) {
println("Holder binding verified for ${matched.credentialQueryId}")
println("Signature valid: ${binding.signatureValid}")
println("Nonce valid: ${binding.nonceValid}")
println("Audience valid: ${binding.audienceValid}")

// For SD-JWT, also check sd_hash
binding.sdHashValid?.let { sdHashValid ->
println("SD Hash valid: $sdHashValid")
}
} else {
println("Holder binding failed: ${binding.errors}")
}
}
}

Response Code Protection

When using direct_post, the wallet sends the VP token directly to your backend, but your frontend still needs access to the result. Response codes solve this: the backend stores the response and returns a short-lived, single-use code in a redirect. The frontend then exchanges that code for the actual response data. This prevents the VP token from leaking through browser history or referrer headers.

// Backend receives direct_post response
fun handleDirectPost(request: HttpRequest): HttpResponse {
val responseParams = mapOf(
"vp_token" to request.formParam("vp_token"),
"state" to request.formParam("state")
)

// Handle response and generate response code
val handleResult = rp.handleDirectPostResponse(
HandleDirectPostResponseArgs(
responseParams = responseParams,
originalRequest = authRequest,
redirectUri = "https://frontend.example.com/callback",
responseCodeTtlSeconds = 300 // 5 minutes
)
)

if (handleResult.isOk) {
val handled = handleResult.value
// Return redirect with response_code
// Browser redirects to: https://frontend.example.com/callback?response_code=xxx
return HttpResponse.redirect(handled.redirectUri)
}
}

// Frontend retrieves the actual response using the code
fun retrieveResponse(responseCode: String) {
val retrieveResult = rp.retrieveAuthorizationResponse(
RetrieveAuthorizationResponseArgs(
responseCode = responseCode,
markAsUsed = true // Single use
)
)

if (retrieveResult.isOk) {
val response = retrieveResult.value
// Now validate the response
}
}

Complete Flow Example

The following example ties together the full verification lifecycle: creating a request, storing the session, building a QR code URI, and then handling the callback by parsing, validating, and verifying holder binding. In production, you would persist the session store across restarts and add appropriate error handling.

class VerifierService(
private val rp: Oid4vpRpService
) {
private val sessionStore = mutableMapOf<String, VerificationSession>()

// Step 1: Create authorization request
suspend fun startVerification(): String {
val nonce = generateSecureNonce()
val state = generateSessionId()

val dcqlQuery = DcqlQuery(
credentials = listOf(
DcqlCredentialQuery(
id = "identity",
format = "dc+sd-jwt",
claims = listOf(
DcqlClaimQuery(path = listOf("given_name")),
DcqlClaimQuery(path = listOf("family_name"))
)
)
)
)

val createResult = rp.createAuthorizationRequest(
CreateAuthorizationRequestArgs(
dcqlQuery = dcqlQuery,
clientId = "https://verifier.example.com",
responseUri = "https://verifier.example.com/callback",
responseMode = ResponseMode.DIRECT_POST,
nonce = nonce,
state = state
)
)

val request = createResult.value.request

// Store session for later validation
sessionStore[state] = VerificationSession(
request = request,
dcqlQuery = dcqlQuery,
nonce = nonce
)

// Build URI for QR code
val uriResult = rp.buildAuthorizationRequestUri(
BuildAuthorizationRequestUriArgs(
request = request,
scheme = Oid4vpUriScheme.OPENID4VP
)
)

return uriResult.value
}

// Step 2: Handle callback
suspend fun handleCallback(vpToken: String, state: String): VerificationResult {
val session = sessionStore[state]
?: return VerificationResult.Error("Unknown session")

// Parse response
val parseResult = rp.parseAuthorizationResponse(
ParseAuthorizationResponseArgs(
responseParams = mapOf("vp_token" to vpToken, "state" to state),
originalRequest = session.request
)
)

if (!parseResult.isOk) {
return VerificationResult.Error("Failed to parse response")
}

// Validate response
val validateResult = rp.validateAuthorizationResponse(
ValidateAuthorizationResponseArgs(
parsedResponse = parseResult.value,
originalRequest = session.request,
dcqlQuery = session.dcqlQuery,
expectedNonce = session.nonce
)
)

if (!validateResult.isOk || !validateResult.value.valid) {
return VerificationResult.Error("Validation failed")
}

// Verify holder binding for each credential
for (matched in validateResult.value.matchedCredentials) {
val bindingResult = rp.verifyHolderBinding(
VerifyHolderBindingArgs(
presentation = matched.presentation,
format = matched.format,
expectedNonce = session.nonce,
expectedAudience = "https://verifier.example.com"
)
)

if (!bindingResult.isOk || !bindingResult.value.verified) {
return VerificationResult.Error("Holder binding verification failed")
}
}

// Clean up session
sessionStore.remove(state)

return VerificationResult.Success(
claims = validateResult.value.matchedCredentials
.flatMap { it.disclosedClaims.entries }
.associate { it.key to it.value }
)
}
}

sealed class VerificationResult {
data class Success(val claims: Map<String, Any?>) : VerificationResult()
data class Error(val message: String) : VerificationResult()
}

data class VerificationSession(
val request: AuthorizationRequest,
val dcqlQuery: DcqlQuery,
val nonce: String
)

Data Types

The types below are the main structures you will work with when building and validating authorization requests and responses.

CreateAuthorizationRequestArgs

data class CreateAuthorizationRequestArgs(
val dcqlQuery: DcqlQuery,
val clientId: String,
val responseUri: String? = null,
val redirectUri: String? = null,
val responseMode: ResponseMode = ResponseMode.DIRECT_POST,
val nonce: String, // Required, minimum 8 characters
val state: String? = null,
val clientMetadata: ClientMetadata? = null,
val clientIdScheme: ClientIdScheme = ClientIdScheme.REDIRECT_URI
)

ValidationResult

data class ValidationResult(
val valid: Boolean,
val matchedCredentials: List<MatchedCredential> = emptyList(),
val errors: List<String> = emptyList()
)

data class MatchedCredential(
val credentialQueryId: String,
val format: String,
val presentation: String,
val disclosedClaims: Map<String, Any?> = emptyMap()
)

HolderBindingResult

data class HolderBindingResult(
val verified: Boolean,
val holderKey: String? = null,
val bindingMethod: String? = null,
val signatureValid: Boolean = false,
val nonceValid: Boolean = false,
val audienceValid: Boolean = false,
val sdHashValid: Boolean? = null,
val errors: List<String> = emptyList()
)