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.
- Android/Kotlin
- iOS/Swift
val rp: Oid4vpRpService = session.graph.oid4vpRpService
let 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.
- Android/Kotlin
- iOS/Swift
// 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
}
// Build the DCQL query
let dcqlQuery = DcqlQuery(
credentials: [
DcqlCredentialQuery(
id: "identity_credential",
format: "mso_mdoc",
meta: ["doctype_value": "org.iso.18013.5.1.mDL"],
claims: [
DcqlClaimQuery(path: ["org.iso.18013.5.1", "family_name"]),
DcqlClaimQuery(path: ["org.iso.18013.5.1", "given_name"]),
DcqlClaimQuery(path: ["org.iso.18013.5.1", "birth_date"])
]
)
]
)
// Create the authorization request
let createResult = try await rp.createAuthorizationRequest(
args: CreateAuthorizationRequestArgs(
dcqlQuery: dcqlQuery,
clientId: "https://verifier.example.com",
responseUri: "https://verifier.example.com/callback",
responseMode: .directPost,
nonce: generateSecureNonce(), // Minimum 8 characters
state: generateSessionId(),
clientIdScheme: .redirectUri
)
)
if createResult.isOk {
let created = createResult.value
let authRequest = created.request
let 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.
- Android/Kotlin
- iOS/Swift
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)
}
let uriResult = try await rp.buildAuthorizationRequestUri(
args: BuildAuthorizationRequestUriArgs(
request: authRequest,
scheme: .openid4vp // openid4vp://
)
)
if uriResult.isOk {
let requestUri = uriResult.value
// requestUri: openid4vp://authorize?client_id=...&response_uri=...&dcql_query=...
// Generate QR code for cross-device flow
let qrImage = generateQRCode(from: requestUri)
// Or use as deep link for same-device flow
openWallet(uri: 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.
- Android/Kotlin
- iOS/Swift
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
)
)
}
let signedResult = try await rp.createSignedAuthorizationRequest(
args: CreateSignedAuthorizationRequestArgs(
request: authRequest,
signingKey: verifierKeyInfo,
clientIdScheme: .x509SanDns
)
)
if signedResult.isOk {
let signedRequest = signedResult.value
// signedRequest.signedJar contains the signed request object (JAR)
// Build URI with signed request
let uriResult = try await rp.buildAuthorizationRequestUri(
args: BuildAuthorizationRequestUriArgs(
request: signedRequest,
scheme: .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.
- Android/Kotlin
- iOS/Swift
// 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
}
}
// For direct_post response mode - handle POST to your callback endpoint
func handleCallback(request: HttpRequest) async throws -> HttpResponse {
let responseParams = [
"vp_token": request.formParam(name: "vp_token"),
"state": request.formParam(name: "state")
]
// Parse the response
let parseResult = try await rp.parseAuthorizationResponse(
args: ParseAuthorizationResponseArgs(
responseParams: responseParams,
originalRequest: authRequest // The original request for correlation
)
)
if parseResult.isOk {
let parsed = parseResult.value
let vpToken = parsed.vpToken
let 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.
- Android/Kotlin
- iOS/Swift
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")
}
}
}
let validateResult = try await rp.validateAuthorizationResponse(
args: ValidateAuthorizationResponseArgs(
parsedResponse: parsed,
originalRequest: authRequest,
dcqlQuery: dcqlQuery,
expectedNonce: originalNonce
)
)
if validateResult.isOk {
let validation = validateResult.value
if validation.valid {
// Access matched credentials
for matched in validation.matchedCredentials {
print("Credential: \(matched.credentialQueryId)")
print("Format: \(matched.format)")
// Access disclosed claims
for (key, value) in matched.disclosedClaims {
print(" \(key): \(value)")
}
}
} else {
// Handle validation errors
for error in validation.errors {
print("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.
- Android/Kotlin
- iOS/Swift
// 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}")
}
}
}
// For each matched credential, verify holder binding
for matched in validation.matchedCredentials {
let bindingResult = try await rp.verifyHolderBinding(
args: VerifyHolderBindingArgs(
presentation: matched.presentation,
format: matched.format,
expectedNonce: originalNonce,
expectedAudience: "https://verifier.example.com"
)
)
if bindingResult.isOk {
let binding = bindingResult.value
if binding.verified {
print("Holder binding verified for \(matched.credentialQueryId)")
print("Signature valid: \(binding.signatureValid)")
print("Nonce valid: \(binding.nonceValid)")
print("Audience valid: \(binding.audienceValid)")
// For SD-JWT, also check sd_hash
if let sdHashValid = binding.sdHashValid {
print("SD Hash valid: \(sdHashValid)")
}
} else {
print("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.
- Android/Kotlin
- iOS/Swift
// 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
}
}
// Backend receives direct_post response
func handleDirectPost(request: HttpRequest) async throws -> HttpResponse {
let responseParams = [
"vp_token": request.formParam(name: "vp_token"),
"state": request.formParam(name: "state")
]
// Handle response and generate response code
let handleResult = try await rp.handleDirectPostResponse(
args: HandleDirectPostResponseArgs(
responseParams: responseParams,
originalRequest: authRequest,
redirectUri: "https://frontend.example.com/callback",
responseCodeTtlSeconds: 300 // 5 minutes
)
)
if handleResult.isOk {
let handled = handleResult.value
// Return redirect with response_code
return HttpResponse.redirect(url: handled.redirectUri)
}
}
// Frontend retrieves the actual response using the code
func retrieveResponse(responseCode: String) async throws {
let retrieveResult = try await rp.retrieveAuthorizationResponse(
args: RetrieveAuthorizationResponseArgs(
responseCode: responseCode,
markAsUsed: true // Single use
)
)
if retrieveResult.isOk {
let 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.
- Android/Kotlin
- iOS/Swift
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
)
class VerifierService {
private let rp: Oid4vpRpService
private var sessionStore: [String: VerificationSession] = [:]
init(rp: Oid4vpRpService) {
self.rp = rp
}
// Step 1: Create authorization request
func startVerification() async throws -> String {
let nonce = generateSecureNonce()
let state = generateSessionId()
let dcqlQuery = DcqlQuery(
credentials: [
DcqlCredentialQuery(
id: "identity",
format: "dc+sd-jwt",
claims: [
DcqlClaimQuery(path: ["given_name"]),
DcqlClaimQuery(path: ["family_name"])
]
)
]
)
let createResult = try await rp.createAuthorizationRequest(
args: CreateAuthorizationRequestArgs(
dcqlQuery: dcqlQuery,
clientId: "https://verifier.example.com",
responseUri: "https://verifier.example.com/callback",
responseMode: .directPost,
nonce: nonce,
state: state
)
)
let request = createResult.value.request
// Store session for later validation
sessionStore[state] = VerificationSession(
request: request,
dcqlQuery: dcqlQuery,
nonce: nonce
)
// Build URI for QR code
let uriResult = try await rp.buildAuthorizationRequestUri(
args: BuildAuthorizationRequestUriArgs(
request: request,
scheme: .openid4vp
)
)
return uriResult.value
}
// Step 2: Handle callback
func handleCallback(vpToken: String, state: String) async throws -> VerificationResult {
guard let session = sessionStore[state] else {
return .error("Unknown session")
}
// Parse response
let parseResult = try await rp.parseAuthorizationResponse(
args: ParseAuthorizationResponseArgs(
responseParams: ["vp_token": vpToken, "state": state],
originalRequest: session.request
)
)
guard parseResult.isOk else {
return .error("Failed to parse response")
}
// Validate response
let validateResult = try await rp.validateAuthorizationResponse(
args: ValidateAuthorizationResponseArgs(
parsedResponse: parseResult.value,
originalRequest: session.request,
dcqlQuery: session.dcqlQuery,
expectedNonce: session.nonce
)
)
guard validateResult.isOk, validateResult.value.valid else {
return .error("Validation failed")
}
// Verify holder binding for each credential
for matched in validateResult.value.matchedCredentials {
let bindingResult = try await rp.verifyHolderBinding(
args: VerifyHolderBindingArgs(
presentation: matched.presentation,
format: matched.format,
expectedNonce: session.nonce,
expectedAudience: "https://verifier.example.com"
)
)
guard bindingResult.isOk, bindingResult.value.verified else {
return .error("Holder binding verification failed")
}
}
// Clean up session
sessionStore.removeValue(forKey: state)
var claims: [String: Any] = [:]
for matched in validateResult.value.matchedCredentials {
for (key, value) in matched.disclosedClaims {
claims[key] = value
}
}
return .success(claims: claims)
}
}
enum VerificationResult {
case success(claims: [String: Any])
case error(String)
}
struct VerificationSession {
let request: AuthorizationRequest
let dcqlQuery: DcqlQuery
let 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()
)