light-mode-image
Learn

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:

  1. Standard OID4VCI custom scheme (openid-credential-offer://) - The baseline scheme defined by the OID4VCI specification. Any app registered to handle this scheme can respond to the offer.

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

  3. 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 OID4VCI 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

  1. Open your project in Xcode.
  2. Select your app target and navigate to the Info tab.
  3. Expand URL Types and verify an entry exists with:
    • Identifier: openid-credential-offer
    • URL Schemes: openid-credential-offer

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

  1. Open your project in Xcode.
  2. Select your app target and navigate to the Info tab.
  3. Expand URL Types and select the + button.
  4. Enter:
    • Identifier: com.yourcompany.wallet
    • URL Schemes: com.yourcompany.wallet

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:

ContentView.swift
// MARK: Handle URL
extension ViewModel {
    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) {
            discoverCredentialOffer(offerString)
            navigationPath.append(NavigationState.credentialOffer)
        }
    }
    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)
    }
}

Then, add an onOpenURL modifier in ContentView to handle incoming custom scheme URLs:

ContentView.swift
            .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.

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:

apple-app-site-association
{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appID": "TEAM_ID.com.yourcompany.wallet",
				"paths": ["/wallet/*"]
			}
		]
	}
}

Replace:

  • TEAM_ID with your Apple Developer Team ID (found in your Apple Developer account).
  • com.yourcompany.wallet with 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-association

Or directly at the root:

https://yourdomain.com/apple-app-site-association

Ensure:

  • The file is served with Content-Type: application/json.
  • The file is accessible via HTTPS without redirects.
  • No .json extension is added to the filename.

Step 3: Enable Associated Domains capability

  1. Open your project in Xcode.
  2. Select your app target and navigate to Signing & Capabilities.
  3. Select + Capability and add Associated Domains.
  4. 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:

ContentView.swift
// MARK: Handle Universal Links
extension ViewModel {
    func handleIncomingURL(_ url: URL) {
        guard url.scheme == "https" && url.host == "yourdomain.com" else { return }
        // Extract the credential offer from the URL
        // Example: https://yourdomain.com/wallet/accept?offer=BASE64_ENCODED_OFFER
        if let offerString = extractCredentialOffer(from: url) {
            discoverCredentialOffer(offerString)
            navigationPath.append(NavigationState.credentialOffer)
        }
    }
    
    func extractCredentialOffer(from url: URL) -> String? {
        // Verify path contains required components
        guard url.pathComponents.contains("wallet"),
              url.pathComponents.contains("accept") else { return nil }
        
        // Extract offer from query parameter
        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)
    }
}

Then, add an onOpenURL modifier in ContentView to handle incoming universal links:

ContentView.swift
            .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.

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:

  1. Standard scheme: openid-credential-offer://?credential_offer=...
  2. Custom scheme: com.yourcompany.wallet://accept/{base64UrlEncodedOffer}
  3. HTTPS scheme: https://yourdomain.com/wallet/accept?offer={base64UrlEncodedOffer}

You can use online QR code generators or create them programmatically for testing.

For iOS, use the following command to test deep links on a connected device:

Test iOS deep link
xcrun simctl openurl booted "com.yourcompany.wallet://accept/BASE64_ENCODED_OFFER"

For Android, use:

Test Android deep link
adb shell am start -a android.intent.action.VIEW -d "com.yourcompany.wallet://accept/BASE64_ENCODED_OFFER"

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:

Test Android App Link
adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/wallet/accept?offer=BASE64_ENCODED_OFFER"

Common issues and troubleshooting

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 with adb 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.

How would you rate this page?

Last updated on

On this page