Skip to content

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:

  1. The delta from custodial mode — what changes for the integrator.
  2. 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.

ConcernCustodial modeNon-Custodial mode
Wallet runtimeHolder Service (TCS-operated)User’s own wallet app
Key custodyIntegrator-managed in their secret manager (see Holder · Manage Wallets & Credentials § 5)User-managed (wallet keeps its own keys)
Credential storageHolder Service databaseUser’s device
Recovery on device lossRecoverable via Holder ServiceWallet-app dependent
Compliance scopeHolder Service is in scopeWallet vendor is in scope
Deployment topologyAll seven servicesSix services (no Holder Service)
  • Issuance API surface. You still call POST /v1/offers exactly as in Issuer · Issue Credential.
  • Verification API surface. You still call POST /v1/verifier/authorization-request and poll GET /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.

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.

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.

If you deploy TCS yourself you can omit Holder Service; six services suffice:

ServiceRequired in non-custodial mode?
Authorization ServerYes
Credential IssuerYes
Verifier ServiceYes
Trust RegistryYes
Schema RegistryYes
SchedulerYes
Holder ServiceNo

The two modes can coexist on a single deployment for different tenants — non-custodial mode does not require a separate cluster.


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.

Rendering diagram...

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"].

Terminal window
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.

Terminal window
curl -X POST https://issuer.turingspace.co/v1/nonce

Response:

{
"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).

The proof JWT proves to the issuer that the wallet controls the binding key.

ClaimValue
issHolder’s DID or client identifier
audIssuer’s credential_issuer URL (from issuer metadata)
iatCurrent Unix timestamp
nonceThe c_nonce from Step 3
HeaderValue
algES256 or EdDSA
typopenid4vci-proof+jwt
jwk or kidChoose 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.

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.

Terminal window
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.

Persist the SD-JWT string and the holder keypair together. Both are needed at presentation time — losing either makes the credential unusable.

Rendering diagram...
openid4vp://?client_id=https://verifier.example.com&request_uri=https://verifier.turingspace.co/v1/verifier/request/abc123

Fetch the JAR (a signed JWT) at request_uri, verify the signature, and decode it.

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.

Take the stored SD-JWT and remove the disclosure entries the user chose not to reveal. Keep the issuer JWT and the chosen disclosures.

The key-binding JWT proves the presenter controls the bound key.

ClaimValue
audVerifier client_id (from the request)
nonceVerifier nonce (from the request)
iatCurrent Unix timestamp
sd_hashbase64url(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.
HeaderValue
algES256 or EdDSA
typkb+jwt
Terminal window
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).

  • aud for the proof JWT must be the credential_issuer URL, not the credential endpoint. Issuer metadata defines this exactly.
  • aud for the key-binding JWT must be the client_id of 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_nonce is fetched from the credential issuer’s /v1/nonce, not from the token endpoint. Per OID4VCI 1.0 Final §7.
  • c_nonce and verifier nonce expire in 300 s. Build the JWT and submit immediately. On invalid_nonce, refetch /v1/nonce and 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”.