URI scheme handling for verification requests
Learn how to configure your holder application to handle different types of authorization request URI schemes for credential presentations
Overview
When verifiers create OID4VP verification requests and share them with holders, they can use different URI schemes to control which wallet applications can handle those requests and how the user experience flows. Your holder application needs to be properly configured to handle the URI schemes that verifiers in your ecosystem are using.
This guide explains the different URI scheme types and shows you how to configure your holder application to handle each type of URI scheme.
Understanding URI schemes in verification requests
A URI scheme is the protocol part at the beginning of a URI (such as https://, mailto:, or custom schemes like mdoc-openid4vp://). When a verifier generates an OID4VP verification request, they choose which URI scheme to use based on their requirements for security, user experience, and control over which apps can respond to the request.
Several URI scheme types can be used for OID4VP verification requests:
-
ISO 18013-7 default scheme (
mdoc-openid4vp://): Defined by ISO/IEC 18013-7:2025. A wallet that handles this scheme is declaring compliance with the full ISO 18013-7 configuration — it signals protocol support, not just routing. This includes, among other requirements, support for ECDH-ES asauthorization_encryption_alg_values_supported. Any app registered to handle this scheme can respond to the authorization request. -
Private-use URI scheme (
com.example.wallet://): A unique custom scheme using reverse-domain notation. This makes it less likely (but not impossible) for other apps to handle the request. Unlike the default schemes above, a private-use scheme carries no implied protocol configuration — the verifier must either know the wallet's supported capabilities out-of-band, or discover them dynamically (for example, via OAuth authorization server metadata). -
Claimed HTTPS scheme (
https://example.com/wallet/...): Uses domain-verified App Links (Android) or Universal Links (iOS) to ensure only your specific app can handle verification requests from your domain. This is the most secure option, but also requires the most setup and coordination with the verifier to ensure the necessary files and settings are correctly implemented.
For a detailed explanation of how these schemes work, their trade-offs, and security considerations, see the OID4VP workflow documentation.
Prerequisites
This guide assumes you have:
- Completed the Remote Presentation Tutorial and have a working holder application that can present a credential remotely via OID4VP. All examples in this guide build on top of the tutorial application.
- Understanding of how OID4VP verification requests are created and structured.
- Access to modify your application configuration and code.
For HTTPS schemes (App Links/Universal Links), you will also need:
- A domain you control.
- Ability to host verification files on your web server.
Configuring URI scheme handling
The configuration required depends on which URI scheme types you want to support and which platform you're developing for.
ISO 18013-7 default scheme (mdoc-openid4vp://)
The mdoc-openid4vp:// scheme is defined by ISO/IEC 18013-7:2025 and signals that your wallet supports the static wallet metadata parameters defined in ISO 18013-7. Register this scheme if your holder application is targeting ISO 18013-7 compliance.
Configure scheme
- Open your project in Xcode.
- Select your app target and navigate to the Info tab.
- Expand URL Types and add a new entry with:
- Identifier:
mdoc-openid4vp - URL Schemes:
mdoc-openid4vp
- Identifier:
Configure intent filter
- Open your
AndroidManifest.xmlfile and add the following intent filter within your main activity:
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mdoc-openid4vp" />
</intent-filter>
</activity>Configure scheme
- Open your
app.config.tsfile and configure the scheme:
export default ({ config }: ConfigContext): ExpoConfig => ({
// ... other config
scheme: "mdoc-openid4vp",
// ... other config
});For Expo projects, this configuration automatically sets up the scheme for both iOS and Android.
Private-use URI scheme
To handle authorization requests using a custom scheme like com.yourcompany.wallet://, you need to register that unique scheme with the operating system.
Step 1: Register your custom scheme
- Open your project in Xcode.
- Select your app target and navigate to the Info tab.
- Expand URL Types and select the + button.
- Enter:
- Identifier:
com.yourcompany.wallet - URL Schemes:
com.yourcompany.wallet
- Identifier:
Step 2: Handle incoming URLs
In your ContentView.swift file, add a handler for the custom scheme URLs. Add the following extension to your ViewModel:
// MARK: Handle URL
extension ViewModel {
func handleIncomingURL(_ url: URL) {
guard url.scheme == "com.yourcompany.wallet" else { return }
// Extract the authorization request from the URL
// The request is typically base64-encoded in the path or as a query parameter
if let requestString = extractAuthorizationRequest(from: url) {
Task {
await createOnlinePresentationSession(authorizationRequestURI: requestString)
}
navigationPath.append(NavigationState.onlinePresentation)
}
}
func extractAuthorizationRequest(from url: URL) -> String? {
// Example: com.yourcompany.wallet://authorize/BASE64_ENCODED_REQUEST
if url.host == "authorize",
let encodedRequest = url.pathComponents.last,
let data = Data(base64URLEncoded: encodedRequest),
let decodedRequest = String(data: data, encoding: .utf8) {
return decodedRequest
}
// Alternative: com.yourcompany.wallet://authorize?request=BASE64_ENCODED_REQUEST
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let requestParam = components.queryItems?.first(where: { $0.name == "request" })?.value,
let data = Data(base64URLEncoded: requestParam),
let decodedRequest = String(data: data, encoding: .utf8) {
return decodedRequest
}
return nil
}
}
// Helper extension for base64 URL decoding
extension Data {
init?(base64URLEncoded string: String) {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let paddingLength = (4 - base64.count % 4) % 4
base64.append(String(repeating: "=", count: paddingLength))
self.init(base64Encoded: base64)
}
}Then, add an onOpenURL modifier in ContentView to handle incoming custom scheme URLs:
.onOpenURL { url in
viewModel.handleIncomingURL(url)
}If your app already handles other types of links, you'll need to update the handleIncomingURL method to support multiple link types.
Step 1: Add intent filter for custom scheme
Open your AndroidManifest.xml file and add a new intent filter for your custom scheme:
<activity android:name=".MainActivity">
<!-- Existing mdoc-openid4vp intent filter -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mdoc-openid4vp" />
</intent-filter>
<!-- New custom scheme intent filter -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.yourcompany.wallet"
android:host="authorize" />
</intent-filter>
</activity>Step 2: Handle incoming intents
In your MainActivity.kt file, add handling for the custom scheme URLs:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Handle intent when activity is created
handleIntent(intent)
setContent {
// ... existing UI code
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle intent when activity is already running
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
val data = intent?.data ?: return
when (data.scheme) {
"mdoc-openid4vp" -> {
// Standard scheme handling (already implemented in tutorial)
val authRequest = data.toString()
navigateToOnlinePresentationScreen(authRequest)
}
"com.yourcompany.wallet" -> {
// Custom scheme handling
val authRequest = extractAuthorizationRequest(data)
if (authRequest != null) {
navigateToOnlinePresentationScreen(authRequest)
}
}
}
}
private fun extractAuthorizationRequest(uri: Uri): String? {
// Example: com.yourcompany.wallet://authorize/BASE64_ENCODED_REQUEST
val encodedRequest = uri.lastPathSegment ?: uri.getQueryParameter("request") ?: return null
return try {
val decodedBytes = android.util.Base64.decode(
encodedRequest.replace('-', '+').replace('_', '/'),
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING
)
String(decodedBytes, Charsets.UTF_8)
} catch (e: IllegalArgumentException) {
null
}
}
private fun navigateToOnlinePresentationScreen(requestUri: String) {
// Navigate to your online presentation screen with the request URI
// Using the same navigation pattern from the tutorial:
// navController.navigate("onlinePresentation")
// Pass requestUri as needed by your navigation implementation
}
}Step 1: Update scheme configuration
Open your app.config.ts file and update the scheme to your custom scheme:
export default ({ config }: ConfigContext): ExpoConfig => ({
// ... other config
scheme: "com.yourcompany.wallet",
// ... other config
});Step 2: Handle incoming URLs
In your /app/_layout.tsx file, add URL handling using Expo's Linking API:
import { useEffect } from "react";
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
export default function RootLayout() {
const router = useRouter();
useEffect(() => {
// Handle URL when app is already open
const subscription = Linking.addEventListener("url", ({ url }) => {
handleIncomingURL(url);
});
// Handle URL when app is opened from a closed state
Linking.getInitialURL().then((url) => {
if (url) {
handleIncomingURL(url);
}
});
return () => {
subscription.remove();
};
}, []);
const handleIncomingURL = (url: string) => {
const parsed = Linking.parse(url);
// Handle custom scheme URLs
if (parsed.scheme === "com.yourcompany.wallet" && parsed.hostname === "authorize") {
const authRequest = extractAuthorizationRequest(url);
if (authRequest) {
router.replace({
pathname: "/online-presentation",
params: { scannedValue: authRequest },
});
}
}
};
const extractAuthorizationRequest = (url: string): string | null => {
try {
const parsed = Linking.parse(url);
// Extract from path: com.yourcompany.wallet://authorize/BASE64_ENCODED_REQUEST
const encodedRequest =
(parsed.path ? parsed.path.split("/").filter(Boolean).pop() : undefined) ??
parsed.queryParams?.request;
if (!encodedRequest) return null;
// Decode base64 URL-encoded request
const base64 = encodedRequest
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(encodedRequest.length + ((4 - (encodedRequest.length % 4)) % 4), "=");
return atob(base64);
} catch (error) {
console.error("Failed to extract authorization request:", error);
return null;
}
};
return (
<HolderProvider>
<Stack />
</HolderProvider>
);
}Step 3: Rebuild native projects
After changing the scheme configuration, regenerate the native projects:
yarn expo prebuild --cleanClaimed HTTPS scheme (App Links / Universal Links)
HTTPS schemes provide the most secure and reliable way to ensure only your specific app handles authorization requests from your domain. This requires coordination with the verifier to ensure the necessary files and settings are correctly implemented.
Step 1: Create the Apple App Site Association file
This step must be performed by the Verifier or the party controlling the domain used in the HTTPS scheme, as it requires hosting a specific file on the web server. If you control both the wallet and the verification service, you can complete this step yourself. Otherwise, you will need to coordinate with the verifier to ensure this file is created and hosted correctly.
Create a file named apple-app-site-association (no file extension) with the following content:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.yourcompany.wallet",
"paths": ["/wallet/*"]
}
]
}
}Replace:
TEAM_IDwith your Apple Developer Team ID (found in your Apple Developer account).com.yourcompany.walletwith your app's bundle identifier./wallet/*with the path pattern you want to handle (you can use multiple paths).
Step 2: Host the association file
Upload the file to your web server at:
https://yourdomain.com/.well-known/apple-app-site-associationOr directly at the root:
https://yourdomain.com/apple-app-site-associationEnsure:
- The file is served with
Content-Type: application/json. - The file is accessible via HTTPS without redirects.
- No
.jsonextension is added to the filename.
Step 3: Enable Associated Domains capability
- Open your project in Xcode.
- Select your app target and navigate to Signing & Capabilities.
- Select + Capability and add Associated Domains.
- Add your domain in the format:
applinks:yourdomain.com
Step 4: Handle Universal Links
In your ContentView.swift file, add a handler for Universal Links. Add the following extension to your ViewModel:
// MARK: Handle Universal Links
extension ViewModel {
func handleIncomingURL(_ url: URL) {
guard url.scheme == "https" && url.host == "yourdomain.com" else { return }
// Extract the authorization request from the URL
// Example: https://yourdomain.com/wallet/authorize?request=BASE64_ENCODED_REQUEST
if let requestString = extractAuthorizationRequest(from: url) {
Task {
await createOnlinePresentationSession(authorizationRequestURI: requestString)
}
navigationPath.append(NavigationState.onlinePresentation)
}
}
func extractAuthorizationRequest(from url: URL) -> String? {
// Verify path contains required components
guard url.pathComponents.contains("wallet"),
url.pathComponents.contains("authorize") else { return nil }
// Extract request from query parameter
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let requestParam = components.queryItems?.first(where: { $0.name == "request" })?.value,
let data = Data(base64URLEncoded: requestParam),
let decodedRequest = String(data: data, encoding: .utf8) {
return decodedRequest
}
return nil
}
}
// Helper extension for base64 URL decoding
extension Data {
init?(base64URLEncoded string: String) {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let paddingLength = (4 - base64.count % 4) % 4
base64.append(String(repeating: "=", count: paddingLength))
self.init(base64Encoded: base64)
}
}Then, add an onOpenURL modifier in ContentView to handle incoming universal links:
.onOpenURL { url in
viewModel.handleIncomingURL(url)
}If your app already handles other types of links, you'll need to update the handleIncomingURL method to support multiple link types.
Step 1: Create the Digital Asset Links file
This step must be performed by the Verifier or the party controlling the domain used in the HTTPS scheme, as it requires hosting a specific file on the web server. If you control both the wallet and the verification service, you can complete this step yourself. Otherwise, you will need to coordinate with the verifier to ensure this file is created and hosted correctly.
Create a file named assetlinks.json with the following content:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.wallet",
"sha256_cert_fingerprints": [
"YOUR_APP_SHA256_FINGERPRINT"
]
}
}
]Replace:
com.yourcompany.walletwith your app's package name.YOUR_APP_SHA256_FINGERPRINTwith your app's SHA-256 certificate fingerprint.
To get your SHA-256 fingerprint, run:
keytool -list -v -keystore your-release-key.keystoreOr for debug builds:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass androidStep 2: Host the association file
Upload the file to your web server at:
https://yourdomain.com/.well-known/assetlinks.jsonEnsure:
- The file is served with
Content-Type: application/json. - The file is accessible via HTTPS without redirects.
Step 3: Add App Links intent filter
Open your AndroidManifest.xml and add an intent filter with android:autoVerify="true":
<activity android:name=".MainActivity">
<!-- Existing intent filters -->
<!-- App Links intent filter -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="yourdomain.com"
android:pathPrefix="/wallet" />
</intent-filter>
</activity>Step 4: Handle App Links
Update your MainActivity.kt to handle App Links:
class MainActivity : ComponentActivity() {
private fun handleIntent(intent: Intent?) {
val data = intent?.data ?: return
when {
// Handle App Links (HTTPS URLs)
data.scheme == "https" && data.host == "yourdomain.com" -> {
handleAppLink(data)
}
// Handle custom schemes
data.scheme == "mdoc-openid4vp" || data.scheme == "com.yourcompany.wallet" -> {
handleCustomScheme(data)
}
}
}
private fun handleAppLink(uri: Uri) {
// Example: https://yourdomain.com/wallet/authorize?request=BASE64_ENCODED_REQUEST
if (uri.pathSegments.contains("wallet") && uri.pathSegments.contains("authorize")) {
val encodedRequest = uri.getQueryParameter("request") ?: return
val authRequest = extractAuthorizationRequest(encodedRequest)
if (authRequest != null) {
navigateToAuthScreen(authRequest)
}
}
}
private fun handleCustomScheme(uri: Uri) {
// ... existing custom scheme handling
}
private fun extractAuthorizationRequest(encodedRequest: String): String? {
return try {
val decodedBytes = android.util.Base64.decode(
encodedRequest.replace('-', '+').replace('_', '/'),
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING
)
String(decodedBytes, Charsets.UTF_8)
} catch (e: IllegalArgumentException) {
null
}
}Step 5: Verify App Links
After installing your app, verify that App Links are working:
adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/wallet/authorize?request=test"You can also check the verification status:
adb shell pm get-app-links com.yourcompany.walletFor React Native with Expo, configuring Universal Links (iOS) and App Links (Android) requires additional setup in the app configuration.
Step 1: Create association files
This step must be performed by the Verifier or the party controlling the domain used in the HTTPS scheme, as it requires hosting a specific file on the web server. If you control both the wallet and the verification service, you can complete this step yourself. Otherwise, you will need to coordinate with the verifier to ensure this file is created and hosted correctly.
Follow the instructions in the iOS and Android tabs to create and host:
apple-app-site-associationfile athttps://yourdomain.com/.well-known/apple-app-site-associationassetlinks.jsonfile athttps://yourdomain.com/.well-known/assetlinks.json
Step 2: Configure app for Universal Links / App Links
Update your app.config.ts:
export default ({ config }: ConfigContext): ExpoConfig => ({
// ... other config
ios: {
bundleIdentifier: "com.yourcompany.wallet",
associatedDomains: ["applinks:yourdomain.com"],
// ... other iOS config
},
android: {
package: "com.yourcompany.wallet",
intentFilters: [
{
action: "VIEW",
autoVerify: true,
data: [
{
scheme: "https",
host: "yourdomain.com",
pathPrefix: "/wallet",
},
],
category: ["BROWSABLE", "DEFAULT"],
},
],
// ... other Android config
},
// ... other config
});Step 3: Handle Universal Links / App Links
Update your /app/_layout.tsx to handle HTTPS URLs:
import { useEffect } from "react";
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
export default function RootLayout() {
const router = useRouter();
useEffect(() => {
const subscription = Linking.addEventListener("url", ({ url }) => {
handleIncomingURL(url);
});
Linking.getInitialURL().then((url) => {
if (url) {
handleIncomingURL(url);
}
});
return () => {
subscription.remove();
};
}, []);
const handleIncomingURL = (url: string) => {
const parsed = Linking.parse(url);
// Handle Universal Links / App Links (HTTPS)
if (parsed.scheme === "https" && parsed.hostname === "yourdomain.com") {
handleAppLink(url);
return;
}
// Handle custom schemes
if (
parsed.scheme === "mdoc-openid4vp" ||
parsed.scheme === "com.yourcompany.wallet"
) {
handleCustomScheme(url);
return;
}
};
const handleAppLink = (url: string) => {
const parsed = Linking.parse(url);
// Example: https://yourdomain.com/wallet/authorize?request=BASE64_ENCODED_REQUEST
if (parsed.path?.includes("/wallet/authorize")) {
const encodedRequest = parsed.queryParams?.request as string;
if (encodedRequest) {
const authRequest = extractAuthorizationRequest(encodedRequest);
if (authRequest) {
router.replace({
pathname: "/online-presentation",
params: { scannedValue: authRequest },
});
}
}
}
};
const handleCustomScheme = (url: string) => {
// ... existing custom scheme handling
};
const extractAuthorizationRequest = (encodedRequest: string): string | null => {
try {
const base64 = encodedRequest
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(encodedRequest.length + ((4 - (encodedRequest.length % 4)) % 4), "=");
return atob(base64);
} catch (error) {
console.error("Failed to decode authorization request:", error);
return null;
}
};
return (
<HolderProvider>
<Stack />
</HolderProvider>
);
}Step 4: Rebuild native projects
After updating the configuration, regenerate the native projects:
yarn expo prebuild --cleanStep 5: Build and test
For iOS, you must test Universal Links on a physical device (not simulator) with a production or ad-hoc build.
For Android, install the app and verify App Links as described in the Android tab above.
Testing your URI scheme implementation
After configuring your app to handle different URI schemes, test each implementation to ensure it works correctly.
Testing with QR codes
Create an authorization request and generate QR codes for each URI scheme type you support:
- ISO 18013-7 scheme:
mdoc-openid4vp://authorize?request=ey... - Custom scheme:
com.yourcompany.wallet://authorize?request=ey... - HTTPS scheme:
https://yourdomain.com/wallet/authorize?request=ey...
You can use online QR code generators or create them programmatically for testing.
Testing with deep links
A deep link works the same way as a QR code — pass the full authorization request URI directly. For iOS, use the following command to test on a simulator:
xcrun simctl openurl booted "com.yourcompany.wallet://authorize?request=ey..."For Android, use:
adb shell am start -a android.intent.action.VIEW -d "com.yourcompany.wallet://authorize?request=ey..."Testing Universal Links / App Links
For iOS Universal Links, you must test on a physical device with a production or ad-hoc build. Links opened in Safari should open your app.
For Android App Links, use:
adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/wallet/authorize?request=ey..."Common issues and troubleshooting
App doesn't open when scanning QR code or tapping link
Possible causes:
- URI scheme not properly registered in the app configuration.
- For Universal Links/App Links, association files not properly hosted or accessible.
- For Universal Links/App Links, domain not added to associated domains / intent filters.
Solutions:
- Verify URL scheme registration in your app configuration.
- Test the association file URLs directly in a browser to ensure they're accessible.
- Check that the association file content matches your app's bundle ID/package name and certificate.
- For iOS, verify Associated Domains capability is enabled and domains are correctly listed.
- For Android, verify
android:autoVerify="true"is set and check verification status withadb shell pm get-app-links.
Wrong app opens when multiple wallet apps are installed
Possible causes:
- Multiple apps registered for the same URI scheme (common with
mdoc-openid4vp://). - Association files not properly verified (for Universal Links/App Links).
Solutions:
- Use a unique custom scheme or domain-verified HTTPS scheme.
- For Universal Links/App Links, ensure association files are correctly configured and verified.
- Test on a clean device or uninstall competing apps during testing.
Encoded request fails to decode
Possible causes:
- Incorrect base64 URL encoding/decoding.
- Request parameter not properly extracted from URL.
Solutions:
- Ensure you're using base64 URL encoding (not standard base64) with
-and_instead of+and/. - Verify padding is handled correctly when decoding.
- Log the encoded and decoded values to debug the transformation.
Related resources
How would you rate this page?
Last updated on