Non-Custodial Mode
In non-custodial mode, the holder runs their own wallet (any standards-conformant OID4VCI / OID4VP wallet) and TCS only operates the issuance and verification edges. There is no Holder Service in this mode — the integration points are Credential Issuer and Verifier Service only.
If you do not yet know which mode you need, read Custodial vs Non-Custodial Mode first.
This page covers two things:
- The delta from custodial mode — what changes for the integrator.
- The wallet-protocol details (proof JWT, key-binding JWT, sd_hash) you need when integrating with an external wallet, building one, or debugging a presentation that the verifier rejects.
Delta from custodial mode
Section titled “Delta from custodial mode”What changes
Section titled “What changes”| Concern | Custodial mode | Non-Custodial mode |
|---|---|---|
| Wallet runtime | Holder Service (TCS-operated) | User’s own wallet app |
| Key custody | Integrator-managed in their secret manager (see Holder · Manage Wallets & Credentials § 5) | User-managed (wallet keeps its own keys) |
| Credential storage | Holder Service database | User’s device |
| Recovery on device loss | Recoverable via Holder Service | Wallet-app dependent |
| Compliance scope | Holder Service is in scope | Wallet vendor is in scope |
| Deployment topology | All seven services | Six services (no Holder Service) |
What stays the same
Section titled “What stays the same”- Issuance API surface. You still call
POST /v1/offersexactly as in Issuer · Issue Credential. - Verification API surface. You still call
POST /v1/verifier/authorization-requestand pollGET /v1/verifier/result/{session_id}exactly as in Verifier · Verify Credential. - Governance. Trust Registry and Schema Registry are required in both modes.
- Standards conformance. Both modes implement OID4VCI v1, OID4VP v1, SD-JWT VC, and DPoP identically.
The mode decision is invisible to the issuer and verifier roles. They cannot tell — and should not need to know — which kind of wallet sits on the other side.
Endpoints you don’t call
Section titled “Endpoints you don’t call”In non-custodial mode you do not call Holder Service:
/v1/user/*,/v1/did/*/v1/oid4vci/*(Holder Service receive)/v1/oid4vp/*(Holder Service presentation)/v1/vc/*
Instead, you deliver deep_link (from offers) and authorization_request (from verifier requests) to the user’s wallet app via QR code, push notification, or universal link.
Wallet compatibility
Section titled “Wallet compatibility”Wallets must support SD-JWT VC with the dc+sd-jwt format, the _sd selective-disclosure mechanism, and key binding via kb+jwt. HAIP-conformant wallets (e.g. EUDI Wallet) interoperate out of the box.
Wallets that only support W3C JWT-VC (vc+jwt) will not interoperate with TCS credentials.
Deployment topology
Section titled “Deployment topology”If you deploy TCS yourself you can omit Holder Service; six services suffice:
| Service | Required in non-custodial mode? |
|---|---|
| Authorization Server | Yes |
| Credential Issuer | Yes |
| Verifier Service | Yes |
| Trust Registry | Yes |
| Schema Registry | Yes |
| Scheduler | Yes |
| Holder Service | No |
The two modes can coexist on a single deployment for different tenants — non-custodial mode does not require a separate cluster.
Wallet-protocol details
Section titled “Wallet-protocol details”The rest of this page is for engineers integrating with an external wallet or building one. If your wallet is conformant, you do not strictly need to know any of this — these are the wire details handled inside the wallet.
These details apply to OID4VCI v1 / OID4VP v1. Custodial-mode integrators do not need them — Holder Service handles all of this internally.
Receiving a credential (wallet view)
Section titled “Receiving a credential (wallet view)”Step 1 — Parse the offer
Section titled “Step 1 — Parse the offer”The issuer delivers a credential offer URI:
openid-credential-offer://?credential_offer_uri=https://issuer.turingspace.co/v1/offers/a1b2c3d4-...Fetch credential_offer_uri to get the offer JSON. The pre-authorized code is at grants["urn:ietf:params:oauth:grant-type:pre-authorized_code"]["pre-authorized_code"].
Step 2 — Exchange the code for a token
Section titled “Step 2 — Exchange the code for a token”curl -X POST https://auth.turingspace.co/v1/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code\&pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA"Response (no c_nonce):
{ "access_token": "eyJhbGciOiJFZERTQSJ9...", "token_type": "Bearer", "expires_in": 86400}The token endpoint may return token_type: "DPoP" and a DPoP-Nonce response header when DPoP is in use.
Transaction code (PIN) variant. When the issuer creates the offer with require_transaction_code: true, the parsed offer URI (Step 1) carries an additional tx_code object instructing the wallet to collect a PIN from the user — typical shape:
"grants": { "urn:ietf:params:oauth:grant-type:pre-authorized_code": { "pre-authorized_code": "SplxlOBeZQQYbYS6WxSbIA", "tx_code": { "length": 6, "input_mode": "numeric", "description": "Enter the 6-digit code shown in your Acme Bank app" } }}Surface that description to the user verbatim, collect the PIN, and add tx_code=<pin> to the form-encoded body of POST /v1/token. Omitting it (or sending the wrong value) returns 400 invalid_grant — the pre-authorized code is then consumed and cannot be retried.
Step 3 — Fetch a fresh c_nonce
Section titled “Step 3 — Fetch a fresh c_nonce”curl -X POST https://issuer.turingspace.co/v1/nonceResponse:
{ "c_nonce": "tZignsnFbp", "c_nonce_expires_in": 300}The nonce endpoint is on the credential issuer (not the authorization server). It is a public POST with no body — no Authorization header is needed for a standalone nonce. If you are using DPoP-bound tokens, send the access token in Authorization: DPoP <token> together with a DPoP proof to refresh the DPoP nonce simultaneously.
The c_nonce is HMAC-signed by the issuer (no DB round-trip), single-use, and short-lived (300 s).
Step 4 — Build the proof JWT
Section titled “Step 4 — Build the proof JWT”The proof JWT proves to the issuer that the wallet controls the binding key.
| Claim | Value |
|---|---|
iss | Holder’s DID or client identifier |
aud | Issuer’s credential_issuer URL (from issuer metadata) |
iat | Current Unix timestamp |
nonce | The c_nonce from Step 3 |
| Header | Value |
|---|---|
alg | ES256 or EdDSA |
typ | openid4vci-proof+jwt |
jwk or kid | Choose exactly one. Use jwk when the wallet holds a standalone keypair with no resolvable DID — embed the public key as a JWK in the header. Use kid only when the holder DID is registered in the Trust Registry and the verifier can resolve it (e.g. kid: "did:iota:testnet:0x...#key-1" or a did:key reference). Sending kid with an unresolvable DID returns 400 invalid_proof. |
Step 5 — Request the credential
Section titled “Step 5 — Request the credential”The /credential endpoint URL is published in issuer metadata as the credential_endpoint field — fetch GET /.well-known/openid-credential-issuer/{tenant} and read it from there. The path intentionally omits the /v1/ prefix so that issuers can publish a deployment-specific endpoint URL without coupling wallets to TCS’s internal versioning. The example below shows the value TCS publishes today.
curl -X POST https://issuer.turingspace.co/credential \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJhbGci..." \ -d '{ "credential_configuration_id": "TuringCerts_Standard_Credential_v2_sd_jwt", "proofs": { "jwt": ["eyJhbGciOiJFUzI1NiIs..."] } }'If the issuer rejects with invalid_nonce, the c_nonce either expired or has already been consumed. Re-run Step 3 to obtain a fresh one and rebuild the proof JWT — do not reuse the previous proof.
Response (200 OK):
{ "credentials": [ { "credential": "eyJhbGciOiJFZERTQSIs...~<disclosure-1>~<disclosure-2>~..." } ]}Each credential is the full SD-JWT VC string in the form <issuer-jwt>~<disclosure-1>~<disclosure-2>~...~. Persist it together with the holder keypair — both are needed at presentation time.
Step 6 — Store the credential
Section titled “Step 6 — Store the credential”Persist the SD-JWT string and the holder keypair together. Both are needed at presentation time — losing either makes the credential unusable.
Presenting a credential (wallet view)
Section titled “Presenting a credential (wallet view)”Step 1 — Parse the request URI
Section titled “Step 1 — Parse the request URI”openid4vp://?client_id=https://verifier.example.com&request_uri=https://verifier.turingspace.co/v1/verifier/request/abc123Fetch the JAR (a signed JWT) at request_uri, verify the signature, and decode it.
Step 2 — Read the DCQL query
Section titled “Step 2 — Read the DCQL query”The JAR contains a dcql_query describing which credential types are accepted, which claims must be disclosed, and which issuers are trusted. See Verifier · DCQL Query Structure.
Step 3 — Match credentials and pick disclosures
Section titled “Step 3 — Match credentials and pick disclosures”Match the request against credentials in your wallet. For each matching credential, choose the minimum set of disclosures that satisfies the request — selective disclosure means you only reveal what is required.
Step 4 — Build the SD-JWT presentation
Section titled “Step 4 — Build the SD-JWT presentation”Take the stored SD-JWT and remove the disclosure entries the user chose not to reveal. Keep the issuer JWT and the chosen disclosures.
Step 5 — Sign the key-binding JWT
Section titled “Step 5 — Sign the key-binding JWT”The key-binding JWT proves the presenter controls the bound key.
| Claim | Value |
|---|---|
aud | Verifier client_id (from the request) |
nonce | Verifier nonce (from the request) |
iat | Current Unix timestamp |
sd_hash | base64url(SHA-256(utf8("<issuer-jwt>~<d1>~<d2>~...~"))) — hash the entire SD-JWT presentation string built in Step 4, including the trailing ~ but excluding the key-binding JWT itself. Encode the digest as base64url with no padding. A single byte of mismatch causes the verifier to reject the presentation with no diagnostic. |
| Header | Value |
|---|---|
alg | ES256 or EdDSA |
typ | kb+jwt |
Step 6 — Submit the response
Section titled “Step 6 — Submit the response”curl -X POST https://verifier.turingspace.co/v1/verifier/presentation \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "vp_token=<sd-jwt-presentation>~<kb-jwt>" \ --data-urlencode 'presentation_submission={...}' \ --data-urlencode "state=<state-from-request-object>"state is required — copy it back unchanged from the verifier’s request object so the verifier can correlate the response with the session it created. Omitting it returns 400.
Response (200 OK):
{ "redirect_uri": "https://verifier.example.com/callback?session_id=b2c3d4e5-..."}redirect_uri is present only if the verifier configured one. The verifier’s session moves to completed; the verifier client polls GET /v1/verifier/result/:sessionId for the verification outcome (see Verifier · Verify Credential § Step 3).
Common wallet-side pitfalls
Section titled “Common wallet-side pitfalls”audfor the proof JWT must be thecredential_issuerURL, not the credential endpoint. Issuer metadata defines this exactly.audfor the key-binding JWT must be theclient_idof the verifier. Not the verifier service URL.- DPoP proofs are single-use and nonce-bound. When the server returns a fresh
DPoP-Nonce, build a new proof — do not reuse the previous one. c_nonceis fetched from the credential issuer’s/v1/nonce, not from the token endpoint. Per OID4VCI 1.0 Final §7.c_nonceand verifiernonceexpire in 300 s. Build the JWT and submit immediately. Oninvalid_nonce, refetch/v1/nonceand rebuild the proof — do not reuse the previous one.- The SD-JWT presentation must end with
~even after stripping disclosures, to indicate “no more disclosures follow before the key-binding JWT”.
What’s next
Section titled “What’s next”- Custodial vs Non-Custodial Mode — the mental model that anchors mode choice.
- Issuer · Issue Credential — the issuance flow (mode-independent).
- Verifier · Verify Credential — the verification flow (mode-independent).
- DIDs —
did:keyis what most non-custodial wallets use for the key-binding JWT. - Architecture & Security — service topology and security boundaries.