URI scheme handling in your holder application
Learn how to configure your holder application to handle different types of credential offer URI schemes from issuers
Overview
When issuers create credential offers, they can use different URI schemes to control which wallet applications can claim those offers and how the user experience flows. Your holder application needs to be properly configured to handle the URI schemes that issuers 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.
Understanding URI schemes in credential claiming
A URI scheme is the protocol part at the beginning of a URI (such as https://, mailto://, or custom schemes like openid-credential-offer://). When an issuer generates a credential offer, they choose which URI scheme to use based on their requirements for security, user experience, and control over which apps can handle the offer.
The three main URI scheme types used for credential offers are:
-
Standard OpenID4VCI custom scheme (
openid-credential-offer://) - The baseline scheme defined by the OpenID4VCI specification. Any app registered to handle this scheme can respond to the offer. -
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 offer. -
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 offers from your domain.
For a detailed explanation of how these schemes work, their trade-offs, and security considerations, see the Credential offer documentation.
Prerequisites
This guide assumes you have:
- Completed the Credential Claiming Tutorial and have a working holder application. All examples in this guide build on top of the tutorial application.
- Understanding of how credential offers 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.
Standard OpenID4VCI custom scheme
The standard openid-credential-offer:// scheme is already configured in the tutorial application. No additional setup is required unless you removed it during development.
Verify scheme configuration
- Open your project in Xcode.
- Select your app target and navigate to the Info tab.
- Expand URL Types and verify an entry exists with:
- Identifier:
openid-credential-offer - URL Schemes:
openid-credential-offer
- Identifier:
Verify intent filter configuration
- Open your
AndroidManifest.xmlfile and verify the following intent filter exists 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="openid-credential-offer" />
</intent-filter>
</activity>Verify scheme configuration
- Open your
app.config.tsfile and verify the scheme is properly configured:
export default ({ config }: ConfigContext): ExpoConfig => ({
// ... other config
scheme: "openid-credential-offer",
// ... other config
});For Expo projects, this configuration automatically sets up the scheme for both iOS and Android.
Private-use URI scheme
To handle offers 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 modifier to your main view:
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
NavigationStack(path: $viewModel.navigationPath) {
// ... existing view code
}
.onOpenURL { url in
handleIncomingURL(url)
}
}
private func handleIncomingURL(_ url: URL) {
guard url.scheme == "com.yourcompany.wallet" else { return }
// Extract the credential offer from the URL
// The offer is typically base64-encoded in the path or as a query parameter
if let offerString = extractCredentialOffer(from: url) {
viewModel.navigationPath.append(NavigationState.credentialOffer(offerString))
}
}
private func extractCredentialOffer(from url: URL) -> String? {
// Example: com.yourcompany.wallet://accept/BASE64_ENCODED_OFFER
if url.host == "accept",
let encodedOffer = url.pathComponents.last,
let data = Data(base64URLEncoded: encodedOffer),
let decodedOffer = String(data: data, encoding: .utf8) {
return decodedOffer
}
// Alternative: com.yourcompany.wallet://accept?offer=BASE64_ENCODED_OFFER
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let offerParam = components.queryItems?.first(where: { $0.name == "offer" })?.value,
let data = Data(base64URLEncoded: offerParam),
let decodedOffer = String(data: data, encoding: .utf8) {
return decodedOffer
}
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)
}
}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 openid-credential-offer 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="openid-credential-offer" />
</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="accept" />
</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) {
"openid-credential-offer" -> {
// Standard scheme handling (already implemented in tutorial)
val credentialOffer = data.toString()
navigateToOfferScreen(credentialOffer)
}
"com.yourcompany.wallet" -> {
// Custom scheme handling
val credentialOffer = extractCredentialOffer(data)
if (credentialOffer != null) {
navigateToOfferScreen(credentialOffer)
}
}
}
}
private fun extractCredentialOffer(uri: Uri): String? {
// Example: com.yourcompany.wallet://accept/BASE64_ENCODED_OFFER
val encodedOffer = uri.lastPathSegment ?: uri.getQueryParameter("offer") ?: return null
return try {
val decodedBytes = android.util.Base64.decode(
encodedOffer.replace('-', '+').replace('_', '/'),
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING
)
String(decodedBytes, Charsets.UTF_8)
} catch (e: IllegalArgumentException) {
null
}
}
private fun navigateToOfferScreen(credentialOffer: String) {
// Navigate to your offer screen with the credential offer
// Implementation depends on your navigation setup
}
}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 === "accept") {
const credentialOffer = extractCredentialOffer(url);
if (credentialOffer) {
router.push({
pathname: "/claim-credential",
params: { scannedValue: credentialOffer },
});
}
}
};
const extractCredentialOffer = (url: string): string | null => {
try {
const parsed = Linking.parse(url);
// Extract from path: com.yourcompany.wallet://accept/BASE64_ENCODED_OFFER
const encodedOffer =
(parsed.path ? parsed.path.split("/").filter(Boolean).pop() : undefined) ??
parsed.queryParams?.offer;
if (!encodedOffer) return null;
// Decode base64 URL-encoded offer
const base64 = encodedOffer
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(encodedOffer.length + ((4 - (encodedOffer.length % 4)) % 4), "=");
return atob(base64);
} catch (error) {
console.error("Failed to extract credential offer:", 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 credential offers from your domain. This requires domain ownership and proper configuration.
Step 1: Create the Apple App Site Association file
This step must be performed by the Credential Issuer or the party controlling the domain used in the HTTPS scheme, as it requires hosting a specific file on the web server. If you are both the issuer and holder, you can complete this step yourself. Otherwise, you will need to coordinate with the issuer 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
Update your ContentView.swift to handle Universal Links:
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
NavigationStack(path: $viewModel.navigationPath) {
// ... existing view code
}
.onOpenURL { url in
handleIncomingURL(url)
}
}
private func handleIncomingURL(_ url: URL) {
// Handle Universal Links (HTTPS URLs)
if url.scheme == "https" && url.host == "yourdomain.com" {
handleUniversalLink(url)
return
}
// Handle custom schemes
if url.scheme == "openid-credential-offer" || url.scheme == "com.yourcompany.wallet" {
handleCustomScheme(url)
return
}
}
private func handleUniversalLink(_ url: URL) {
// Example: https://yourdomain.com/wallet/accept?offer=BASE64_ENCODED_OFFER
guard url.pathComponents.contains("wallet"),
url.pathComponents.contains("accept") else { return }
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let offerParam = components.queryItems?.first(where: { $0.name == "offer" })?.value,
let data = Data(base64URLEncoded: offerParam),
let decodedOffer = String(data: data, encoding: .utf8) {
viewModel.navigationPath.append(NavigationState.credentialOffer(decodedOffer))
}
}
private func handleCustomScheme(_ url: URL) {
// ... existing custom scheme handling
}
}Step 1: Create the Digital Asset Links file
This step must be performed by the Credential Issuer or the party controlling the domain used in the HTTPS scheme, as it requires hosting a specific file on the web server. If you are both the issuer and holder, you can complete this step yourself. Otherwise, you will need to coordinate with the issuer 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 == "openid-credential-offer" || data.scheme == "com.yourcompany.wallet" -> {
handleCustomScheme(data)
}
}
}
private fun handleAppLink(uri: Uri) {
// Example: https://yourdomain.com/wallet/accept?offer=BASE64_ENCODED_OFFER
if (uri.pathSegments.contains("wallet") && uri.pathSegments.contains("accept")) {
val encodedOffer = uri.getQueryParameter("offer") ?: return
val credentialOffer = extractCredentialOffer(encodedOffer)
if (credentialOffer != null) {
navigateToOfferScreen(credentialOffer)
}
}
}
private fun handleCustomScheme(uri: Uri) {
// ... existing custom scheme handling
}
private fun extractCredentialOffer(encodedOffer: String): String? {
return try {
val decodedBytes = android.util.Base64.decode(
encodedOffer.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/accept?offer=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 Credential Issuer or the party controlling the domain used in the HTTPS scheme, as it requires hosting a specific file on the web server. If you are both the issuer and holder, you can complete this step yourself. Otherwise, you will need to coordinate with the issuer 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 === "openid-credential-offer" ||
parsed.scheme === "com.yourcompany.wallet"
) {
handleCustomScheme(url);
return;
}
};
const handleAppLink = (url: string) => {
const parsed = Linking.parse(url);
// Example: https://yourdomain.com/wallet/accept?offer=BASE64_ENCODED_OFFER
if (parsed.path?.includes("/wallet/accept")) {
const encodedOffer = parsed.queryParams?.offer as string;
if (encodedOffer) {
const credentialOffer = extractCredentialOffer(encodedOffer);
if (credentialOffer) {
router.push({
pathname: "/claim-credential",
params: { scannedValue: credentialOffer },
});
}
}
}
};
const handleCustomScheme = (url: string) => {
// ... existing custom scheme handling
};
const extractCredentialOffer = (encodedOffer: string): string | null => {
try {
const base64 = encodedOffer
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(encodedOffer.length + ((4 - (encodedOffer.length % 4)) % 4), "=");
return atob(base64);
} catch (error) {
console.error("Failed to decode credential offer:", 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 a Credential offer and generate QR codes for each URI scheme type you support:
- Standard scheme:
openid-credential-offer://?credential_offer=... - Custom scheme:
com.yourcompany.wallet://accept/{base64UrlEncodedOffer} - HTTPS scheme:
https://yourdomain.com/wallet/accept?offer={base64UrlEncodedOffer}
You can use online QR code generators or create them programmatically for testing.
Testing with deep links
For iOS, use the following command to test deep links on a connected device:
xcrun simctl openurl booted "com.yourcompany.wallet://accept/BASE64_ENCODED_OFFER"For Android, use:
adb shell am start -a android.intent.action.VIEW -d "com.yourcompany.wallet://accept/BASE64_ENCODED_OFFER"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/accept?offer=BASE64_ENCODED_OFFER"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
openid-credential-offer://). - 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 offer fails to decode
Possible causes:
- Incorrect base64 URL encoding/decoding.
- Offer 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?