Going live

How to take a Tessio.Verifier app from the built-in Demo, Mock and Test modes to real wallets.

The built-in modes exist so you can build and test without a wallet. Going live means four things change: the mode, the request signature, the trust list and the session store. Each is a service you register before AddTessioVerifier, which uses TryAdd for everything and therefore keeps whatever you registered first. Response encryption (§6) needs no change for a single instance and one deliberate design choice when you scale out.

builder.Services.AddSingleton<IPresentationRequestBuilder>(...);   // 2. signed requests
builder.Services.AddSingleton<ITrustListResolver>(...);            // 3. real trust list
builder.Services.AddSingleton<ISessionStore>(...);                 // 4. shared sessions (multi-instance)
builder.Services.AddSingleton(new ResponseEncryptionKeyProvider(...)); // 5. shared decryption key (multi-instance)

builder.Services.AddTessioVerifier(options =>
{
    options.Mode = VerifierMode.Live;                              // 1. no built-in actor completes sessions
    options.ClientId = "x509_san_dns:verifier.example.com";
    options.ExpectedVct = "urn:eudi:pid:1";
    options.RequestedClaims = ["age_over_18"];
});

A single-instance deployment only needs steps 1 to 3. Steps 4 and 5 matter once you scale past one process.

1. Live mode

options.Mode = VerifierMode.Live;

In Live mode a started session stays pending until a wallet posts to the callback endpoint or the session lifetime (options.SessionLifetime, default 5 minutes) runs out. Everything else is identical to Mock mode, which is the point: Mock exercises the exact pipeline a live wallet hits.

Live mode also checks the configuration at startup and refuses to run with the demo request builder or the dev trust list still registered, so a demo configuration cannot quietly face real wallets. No demo, mock or test background services are hosted in Live mode.

The endpoints MapTessioVerifier exposes (default prefix /verify):

Endpoint Role
GET /verify/start Creates a session and renders the request page with the openid4vp:// authorization URI
GET /verify/request/{id} Serves the signed request object (by-reference delivery, see below)
GET /verify/{sessionId} Session status as JSON, for your own frontend
GET /verify/{sessionId}/stream Server-Sent Events: pending, then completed or expired
POST /verify/callback The wallet's response_uri. Returns 200 on completion, 400 for invalid or unknown responses, 409 for replays

The callback endpoint enforces state correlation and completes each session exactly once, so replayed responses get a 409 and stray posts a 400.

2. Sign your requests

Live wallets require JAR-signed request objects (RFC 9101). Replace the default demo builder with SignedPresentationRequestBuilder and the key behind your wallet-facing certificate:

using Tessio.Verifier.OpenId4Vp;

var cert = ...; // your WRPAC or access certificate, e.g. from a store or Key Vault
builder.Services.AddSingleton<IPresentationRequestBuilder>(new SignedPresentationRequestBuilder(
    new PresentationRequestBuilderOptions
    {
        SigningCredentials = new SigningCredentials(
            new ECDsaSecurityKey(cert.GetECDsaPrivateKey()!), SecurityAlgorithms.EcdsaSha256),
    }));

Any SigningCredentials works. For a key that never leaves Azure Key Vault or an HSM, point IdentityModel's signing at the remote key with a custom CryptoProviderFactory:

using Azure.Security.KeyVault.Keys.Cryptography;
using Microsoft.IdentityModel.Tokens;

sealed class KeyVaultCryptoProviderFactory(CryptographyClient client) : CryptoProviderFactory
{
    public override SignatureProvider CreateForSigning(SecurityKey key, string algorithm) =>
        new KeyVaultSignatureProvider(client, key, algorithm);
}

sealed class KeyVaultSignatureProvider(CryptographyClient client, SecurityKey key, string algorithm)
    : SignatureProvider(key, algorithm)
{
    public override byte[] Sign(byte[] input) =>
        client.SignData(SignatureAlgorithm.ES256, input).Signature;

    public override bool Verify(byte[] input, byte[] signature) => throw new NotSupportedException();

    protected override void Dispose(bool disposing) { }
}
var client = new CryptographyClient(new Uri("https://myvault.vault.azure.net/keys/verifier-jar"), credential);
var publicKey = new ECDsaSecurityKey(publicEcdsa); // public half, exported once from the vault

builder.Services.AddSingleton<IPresentationRequestBuilder>(new SignedPresentationRequestBuilder(
    new PresentationRequestBuilderOptions
    {
        SigningCredentials = new SigningCredentials(publicKey, SecurityAlgorithms.EcdsaSha256)
        {
            CryptoProviderFactory = new KeyVaultCryptoProviderFactory(client),
        },
    }));

Set options.ClientId to your registered identifier with its client-identifier prefix, for example x509_san_dns:verifier.example.com. The prefix tells the wallet how to validate your request against your certificate.

3. Deliver the request by reference

By default the signed request object is embedded in the openid4vp:// URI. The start page renders that URI as a QR code for cross-device scanning, and a multi-kilobyte by-value JAR makes a dense code or exceeds QR capacity entirely (the page then shows the URI without a code). Set RequestUriBase and the wallet fetches the JAR over HTTPS instead, keeping the QR small:

new PresentationRequestBuilderOptions
{
    SigningCredentials = ...,
    RequestUriBase = new Uri("https://verifier.example.com/verify/request"),
}

Point it at {your host}{route prefix}/request. MapTessioVerifier already serves stored request objects there with the required application/oauth-authz-req+jwt content type, and the start endpoint stores each session's JAR automatically. Request objects expire with their session.

4. Supply a real trust list

The default resolver trusts only the built-in demo and mock issuers, so real credentials will verify but report Trusted = false and fail. Live mode refuses to start with the default in place; register your own resolver before AddTessioVerifier.

How much you need to configure depends on how issuers prove their keys:

  • Issuer metadata (iss HTTPS URI): the identifier is proven by control of the issuer's domain, so listing the identifier is enough.
  • X.509 (x5c header): the signing key comes from the presented certificate, so the identifier proves nothing on its own. Anyone can put a trusted issuer's name in a self-signed certificate. StaticTrustListResolver therefore requires the chain to anchor on a certificate you configure, and rejects x5c credentials when no anchors are set.
using Tessio.Verifier.Trust;

builder.Services.AddSingleton<ITrustListResolver>(new StaticTrustListResolver(
    ["https://pid-issuer.example.de"],
    source: "my-trust-list",
    trustAnchors: [rootCertificate]));   // CA roots or pinned issuer certificates

For metadata-only issuers a plain identifier list works, loaded from a JSON document of the form {"trusted_issuers": ["https://issuer.example", ...]} on disk or at an HTTPS URL:

builder.Services.AddSingleton<ITrustListResolver>(
    await TrustListLoader.LoadAsync("trusted-issuers.json"));

Or implement ITrustListResolver directly. It receives the credential's issuer identifier and its X.509 chain when one is present, and returns an IssuerTrustStatus. This is the seam for LOTL-derived national trust lists, your own registry or a managed trust service:

public sealed class MyTrustResolver : ITrustListResolver
{
    public Task<IssuerTrustStatus> ResolveAsync(
        string issuer, ReadOnlyMemory<byte>[] x5c, CancellationToken ct = default)
    {
        // Look the issuer up in your trust source; inspect the chain if you anchor on certificates.
    }
}

5. Share sessions across instances

The default InMemorySessionStore is process-local. Behind a load balancer the wallet's callback can land on a different instance than the one that started the session, so the store must be shared. Implement IStateCorrelatingSessionStore over Redis, SQL or any shared storage:

public sealed class RedisSessionStore : IStateCorrelatingSessionStore
{
    public Task<VerificationSession> CreateAsync(PresentationRequestOptions options, CancellationToken ct = default);
    public Task<VerificationSession?> GetAsync(string sessionId, CancellationToken ct = default);
    public Task<VerificationSession?> FindByStateAsync(string state, CancellationToken ct = default);
    public Task CompleteAsync(string sessionId, VerificationResult result, CancellationToken ct = default);
}
builder.Services.AddSingleton<ISessionStore, RedisSessionStore>();

FindByStateAsync is the extra member beyond the base ISessionStore: a wallet response carries only the OpenID4VP state value, so the callback path needs a state index next to the sessions. Registering a store without it fails fast with an explanatory exception on the first callback.

If you drive creation and completion from your own API (bypassing /start and /callback) or host many tenants in one process, see self-driving-and-multi-tenant.md for the IWalletResponseVerifier seam, a durable Postgres store and the request-object round-trip.

Two behaviors to know:

  • CreateAsync builds the presentation request (inject IPresentationRequestBuilder) and must index the request's state. Complete each session at most once and treat later completions as no-ops or conflicts.
  • The SSE stream endpoint gets push notifications from the in-memory store. With a custom store it polls GetAsync every 500 ms until the session leaves Pending, which works unchanged with any store.

6. Response encryption across instances

With ResponseMode.DirectPostJwt (the default and the HAIP baseline) wallets encrypt their responses to a key published in your request's client_metadata.jwks.

The key is ephemeral per authorization request, not per process. OpenID4VP 1.0 §8.3 and HAIP 1.0 §5 require this, and the conformance suite fails a verifier that reuses one. The reasons are real: a single long-lived key means its compromise retrospectively exposes every response ever encrypted against it, and a stable advertised public key is a correlation handle tying separate presentations to one verifier. ResponseEncryptionKeyStore generates a fresh P-256 key at /verify/start, holds it in memory, and the callback finds it again by the kid (RFC 7638 thumbprint) the wallet echoes in the JWE header. The private half never touches disk.

Resolving by kid is spec-mandated, so decryption requires it with no fallback. OpenID4VP 1.0 §5 requires that every JWK in client_metadata.jwks carry a kid, and §8.3 then requires that "if the selected public key contains a kid parameter, the JWE MUST include the same value in the kid JWE Header Parameter" of the encrypted response. The advertised key always has a kid, so a conformant wallet always echoes it. A response that carries none, or names a kid the store no longer holds, is rejected rather than decrypted against a guessed key: for direct_post.jwt the state that correlates the session lives inside the ciphertext, so the kid is the only handle available before decryption, and quietly trying "the only key we have" would reintroduce the shared-key ambiguity this design exists to remove. Failing loud surfaces a non-conformant wallet instead of masking it.

That default is complete for a single process. Across instances it breaks the same way sessions do: instance A generates the key and holds it in its own memory, so instance B cannot decrypt a response routed to it.

Recommended when you scale out: derive the key, store no key material. Do not persist private keys, not even encrypted. Keep one master secret in a KMS or HSM, ideally non-exportable, and derive each request's private scalar from it:

d    = HKDF(master, salt)      // salt: a fresh random value per request
kid  = base64url SHA-256 thumbprint of the public JWK

Store only the non-secret salt, keyed by kid, in the shared store you already run for sessions (§5). At the callback, any instance reads the salt for the response's kid, re-derives d from the master secret it reaches via the KMS, and decrypts. The database holds a random salt and nothing secret; the only long-lived secret is the master, which can live in hardware and never be exported.

Why not the alternatives:

  • Persisting the private key, even as KMS-encrypted ciphertext in Postgres, puts key material in your database. Derivation avoids that entirely.
  • Sticky sessions (route the callback to the instance that issued the request) store nothing, but couple you to load-balancer affinity and lose the key if that instance restarts mid-flow. A reasonable stopgap, not the target.

This derivation path is not yet wired into the library, deliberately: nothing consumes it while Tessio Cloud is single-instance, and adding the seam before a second instance needs it would be speculative (see the extract-when-needed rule). When you scale out, the change is a pluggable key source on ResponseEncryptionKeyStore (create-from-salt, resolve-by-kid) backed by your KMS. Until then the in-memory default is correct and needs no configuration.

ResponseMode.DirectPost (cleartext form posts) is also supported, but encrypted responses are the profile default so stay on DirectPostJwt unless you have a reason not to.

Requesting mdocs instead of SD-JWT VC

The same pipeline verifies ISO mobile documents (the mDL and the mdoc flavour of the PID). One option switches the credential format:

builder.Services.AddTessioVerifier(options =>
{
    options.Mode = VerifierMode.Live;
    options.CredentialFormat = "mso_mdoc";
    options.ExpectedDocType = "org.iso.18013.5.1.mDL";   // or eu.europa.ec.eudi.pid.1
    options.MdocNamespace = "org.iso.18013.5.1";
    options.RequestedClaims = ["age_over_18"];
});

The DCQL query then uses doctype_value and [namespace, element] claim paths, and responses are verified by MdocVerifier: issuer signature via the x5chain Document Signer certificate, per-item digests against the Mobile Security Object, validity window and the device signature over the OpenID4VP session transcript (which binds your client_id, nonce, response_uri and response-encryption key).

Two things differ from the SD-JWT path:

  • Trust anchors are mandatory. mdoc trust is X.509 only (IACA roots), so your ITrustListResolver must anchor chains; an identifier list alone rejects everything. Pass the IACA certificates as trustAnchors and list the Document Signer subjects as issuers.
  • Stay on direct_post.jwt. The high-assurance profile requires encrypted responses for mdocs, and the encryption key's thumbprint is part of what the wallet's device signature covers.

Mock mode works the same way: with CredentialFormat = "mso_mdoc" the built-in wallet issues real device-signed DeviceResponses, so you can exercise the whole mdoc flow offline. The mdoc pipeline is verified against external artifacts at every layer: the ISO 18013-5 Annex D worked example (parsing, digests and issuer signature agree with the spec's own bytes), the OpenID4VP Annex B.2.6 transcript vectors (byte-identical to the EUDI reference wallet's own test expectations) and a cross-implementation round trip in which an independent mdoc stack (OpenWallet Foundation wallet-framework-dotnet) built and signed the device authentication that this verifier accepts.

Adjusting verification policy

SdJwtVcVerifier defaults are strict: key binding required, credential status (Token Status List) checked and failing closed, 5 minutes of clock skew. To change them, register the verifier yourself:

using Tessio.Verifier.Core;

builder.Services.AddSingleton<ICredentialVerifier>(sp => new SdJwtVcVerifier(
    sp.GetRequiredService<ITrustListResolver>(),
    new SdJwtVcVerifierOptions
    {
        ClockSkew = TimeSpan.FromMinutes(2),
        // RequireKeyBinding = false,   // only for credentials without cnf
        // CheckStatus = false,         // only if you accept revoked-credential risk
    },
    clock: sp.GetRequiredService<TimeProvider>()));

Verification results carry stable error codes (nonce_mismatch, untrusted_issuer, credential_revoked and so on) in VerificationResult.Errors, so your application logic and your logs can branch on codes rather than messages.

Binding transactions into presentations

For flows where the credential authorizes a specific act (a payment, a contract signature), OpenID4VP transaction data binds the holder's signature to that act. Supply the transaction objects and the rest is automatic: they ride base64url-encoded in the signed request, the wallet hashes each one into its Key Binding JWT and the verifier rejects presentations whose hashes are missing or wrong.

options.TransactionData = ["""{"type":"payment_confirmation","amount":"120.00 EUR"}"""];

credential_ids defaults to the query's credential id. Failures surface as transaction_data_missing, transaction_data_hash_mismatch or transaction_data_alg_unsupported. SD-JWT VC flows only; the mechanism requires key binding.

Observability

The pipeline logs through Microsoft.Extensions.Logging, so whatever your host configures (console, Application Insights, OpenTelemetry) picks it up with no extra wiring. Session creation logs under Tessio.Verifier.Sessions; callback handling logs under the WalletCallbackProcessor category. Every rejected wallet response logs a warning with the cause (parse failure, missing or unknown state, replay) and every completion logs the verification outcome with its error codes. Disclosed claims and credential contents are never logged.

What the library does not cover

Verifying real EUDI wallets in production also requires registering as a relying party in your member state and holding a Wallet Relying Party Access Certificate (WRPAC) from a Qualified Trust Service Provider, plus maintained EU trust lists. See docs/production.md for that landscape. The library covers the protocol and the credential verification; the registration and trust layer is yours or a provider's.

Checklist

  • [ ] VerifierMode.Live
  • [ ] SignedPresentationRequestBuilder with your certificate's key (HSM or Key Vault via CryptoProviderFactory)
  • [ ] ClientId set to your registered identifier with its prefix
  • [ ] RequestUriBase set so QR codes stay small
  • [ ] Real ITrustListResolver registered, with trust anchors for issuers that use x5c
  • [ ] Multi-instance: IStateCorrelatingSessionStore over shared storage
  • [ ] Multi-instance: ResponseEncryptionKeyProvider built from one persisted key
  • [ ] Callback endpoint reachable over HTTPS from the public internet
  • [ ] Verified end to end in Mock mode first, then against a reference wallet