Delegated access
How Verifiably lets one party (a delegate) prove they are authorised to act
on behalf of another (the principal) β a lawyer for a client, a director
for a company, a parent for a child β and how a verifier decides whether to honour
that authority. This page walks the whole mechanism: the model, the evaluator, the
two credentials, the formats, issuance, verification UX, revocation, and the known
limits.
1. The model in one paragraph
Delegated access is carried by a pair of credentials, presented together:
| Credential | Held by | Says |
|---|---|---|
| Identity (subject) | the delegate's wallet | who the principal is (e.g. a Pet Card / national ID), anchored on a stable subjectRef |
| Delegation | the delegate's wallet | this delegate may perform allowedAction on behalf of onBehalfOf, until validUntil |
The delegate presents both in one OID4VP request. The verifier runs the normal
credential checks (signature, expiry, status) and a delegated-access evaluator
that answers one question: is this presenter authorised to act for this subject?
issues issues
issuer ββββββββΆ Identity cred issuer ββββββββΆ Delegation cred
β β
βββββββββββββ both claimed βββββββββββββ
β
delegate's wallet
β presents the pair (OID4VP)
βΌ
verifier
β host verifies sig / binding / status
βΌ
internal/delegation.Evaluate(...)
linkage Β· invocation Β· capability Β· revocation
βΌ
AUTHORISED / DENIED
2. The evaluator (internal/delegation)
The evaluator is DPG- and format-agnostic and never re-verifies signatures or
holder binding β that is the host verifier's job, and we trust its verdict. It
owns exactly the four checks no deployed DPG performs:
Package doc (
internal/delegation/delegation.go):
"The evaluator owns exactly the four checks no deployed DPG performs: linkage,
invocation binding, capability/caveats, and uniform revocation status. Status and
trust lookups are injected as functions so this package stays pure and
unit-testable."
It runs over a normalized, per-credential view of the presentation
(backend.NormalizedCredential) that each verifier adapter populates, with status
and trust injected as function options:
func Evaluate(ctx context.Context,
creds []backend.NormalizedCredential,
holder *backend.HolderBinding,
opts Options) backend.DelegationResult
Evaluate first locates a delegation credential; if there is none it returns
{Evaluated:false} and the base verdict is left untouched. Otherwise it runs the
four checks, short-circuiting on the first failure with a human-readable Reason.
Check 1 β Linkage: is this delegation about the presented subject?
// onBehalfOf may name the principal by their subjectRef, DID, or any disclosed
// identifier (subjectIdentifies); the error lists what was available.
if cap.OnBehalfOf == "" || !subjectIdentifies(identity, cap.OnBehalfOf) {
res.Reason = fmt.Sprintf("linkage failed: delegation onBehalfOf %q matches none "+
"of the identity credential's identifiers %v β at issuance, set onBehalfOf "+
"to the holder's subjectRef, an identifier field, or DID",
cap.OnBehalfOf, subjectIdentifiers(identity))
return res
}
res.Linkage = true
The delegation's onBehalfOf must match a unique identifier the identity
credential carries β see Β§5 Linkage anchoring.
Check 2 β Invocation: is the presenter the bound delegate?
The delegate is the holder-bound credential subject (credentialSubject.id,
rebound to the presenting holder by OID4VCI at claim time). When the host surfaces
a confirmed holder binding, the presenter must equal that delegate:
delegate := deleg.SubjectID
if delegate == "" { delegate = cap.Delegate }
// holder-bound model: a confirmed binding establishes the delegate even when the
// credential names none (e.g. Inji auth-code proves possession at CLAIM time).
if delegate == "" && (holder == nil || !holder.Confirmed) {
res.Reason = "delegation credential names no delegate"; return res
}
if holder != nil && holder.Confirmed && delegate != "" {
if hid := holderRef(holder); hid != "" && !sameRef(hid, deleg.SubjectID) && !sameRef(hid, cap.Delegate) {
res.Reason = fmt.Sprintf("invocation failed: presenter %q is neither the "+
"delegation subject nor the named delegate", hid)
return res
}
}
res.Invocation = true
Gotcha: if the wallet binds the identity and delegation to different holder
DIDs (e.g.did:keyvsdid:jwkacross claim sessions), invocation fails. Claim
both credentials into the same wallet/DID. See Β§10 Wallet limits.
Check 3 β Capability: is the action permitted, within validity, no chain?
if cap.HasChain { res.Reason = "re-delegation chains are not supported in v1"; return res }
if cap.Controller != "" && deleg.Issuer != "" && !sameRef(cap.Controller, deleg.Issuer) {
res.Reason = "capability controller is not the credential issuer"; return res
}
if cap.ValidUntil != "" { /* parse + reject if opts.now().After(until) */ }
if opts.RequestedAction != "" && len(cap.AllowedAction) > 0 &&
!containsFold(cap.AllowedAction, opts.RequestedAction) {
res.Reason = fmt.Sprintf("action %q is not permitted by the delegation (allowed: %s)",
opts.RequestedAction, strings.Join(cap.AllowedAction, ", "))
return res
}
res.Capability = true
Gotcha: the verifier's default
RequestedActionispresent. If your
allowedActiondoesn't includepresent, the presentation act itself is denied β
includepresentalongside the domain actions.
Check 4 β Revocation: neither credential is revoked (uniform across formats)
for _, c := range []backend.NormalizedCredential{identity, deleg} {
ref, has := statusRef(c)
if !has { continue }
revoked, err := opts.Status(ctx, ref)
if err != nil && opts.FailClosed {
res.Reason = fmt.Sprintf("revocation status unavailable for %s (fail-closed)", ref.URI)
return res
}
if revoked { res.Reason = "a presented credential has been revoked"; return res }
}
When all four pass (and trust, when enforced), Authorized = true. The verdict
renders as a card on the verifier and the holder consent screen:
π DELEGATED ACCESS β AUTHORISED
Linkage pass β
Invocation pass β
Capability pass β
Not revoked pass β
3. The capability β one normalized shape, three encodings
The decision logic reads a single Capability struct regardless of how the
delegation credential encoded its authority:
type Capability struct {
Controller string // root authority; must equal the credential issuer
OnBehalfOf string // the subject the delegate acts for (linkage anchor)
Delegate string // the delegate (should equal the delegation subject)
AllowedAction []string // permitted actions; empty => unconstrained
ValidUntil string // RFC3339; empty => no caveat (status list governs)
...
}
extractCapability (in internal/delegation/extract.go) fills it from whichever
shape is present, in order:
- JSON-LD
termsOfUse(W3C VCDM, walt.id) β aDelegationCapabilityentry:"termsOfUse": [{ "type": "DelegationCapability", "invocationTarget": "urn:person:bosco", "allowedAction": ["present", "consent:disclose"], "caveat": [{ "type": "ValidWhile", "validUntil": "2033-03-10T00:00:00Z" }] }] - SD-JWT
delegationclaim (object or JSON string). - Flat top-level claims β
onBehalfOf,allowedAction,validUntil
(Inji Certify can't nest, so the capability is carried as flat claims).
Because vp.FromVCObject flattens credentialSubject into Claims, a JSON-LD
ldp_vc capability resolves with no extra code β the flat path reads it via
Claims. This is why one evaluator absorbs every DPG's quirks.
4. Issuance β building the pair
POST /api/v1/delegation/issue registers the two credential types once
(idempotent), then issues both. The shared core is issueDelegationPairCore
(internal/handlers/api_delegation_bulk.go). For W3C the bodies are supplied
verbatim via a CredentialData override; for SD-JWT via flat SubjectData
claims. The delegation body is built by delegation.BuildDelegationCredential:
// Always carry the canonical DelegatedAccessCredential marker (the evaluator's
// hook) AND, when distinct, the catalog/scenario type so a verifier can request
// the pair by either name (e.g. "PowerOfAttorney", "PetAccessCredential").
types := []string{"VerifiableCredential", "DelegatedAccessCredential"}
if d.Type != "" && d.Type != "DelegatedAccessCredential" { types = append(types, d.Type) }
doc := map[string]any{
"@context": contextArr(d.DataModel, d.ContextURL),
"type": types,
"credentialSubject": cs, // { onBehalfOf:{id}, role, id:<delegate> }
"termsOfUse": []any{capability},
}
Request shape:
POST /api/v1/delegation/issue
{
"issuerDpg": "Walt Community Stack",
"std": "w3c_vcdm_2", // | "w3c_vcdm_1" | "sd_jwt_vc (IETF)"
"subject": { "type": "PetCard", "subjectRef": "urn:pet:bosco",
"claims": { "givenName": "Bosco" } },
"delegation": { "type": "PetAccessCredential", "role": "Owner",
"allowedAction": ["present","access"],
"validUntil": "2033-03-10T00:00:00Z" }
}
The response returns an offer URI + credentialId for each leg, plus the status-list
binding. Bulk (POST /api/v1/delegation/issue/bulk) fans the same core out
over rows via the async job queue β it registers the types once, then issues a
pair per row (re-registering per row would restart walt.id's issuer-api).
5. Linkage anchoring
The verifier links the two credentials on a unique identifier:
delegation.onBehalfOf must equal one of the identity credential's identifiers.
subjectIdentifiers (extract.go) defines what counts:
func subjectIdentifiers(c backend.NormalizedCredential) []string {
// ... add subjectRef (any source) + the subject DID + identifier-named fields
add(mapStr(cs, "subjectRef")); add(c.SubjectID)
for k, v := range c.Claims {
if isIdentifierFieldName(k) { add(v) } // testa_id, nationalId, uin, β¦
}
}
// isIdentifierFieldName: an "β¦id / β¦number / β¦ref" suffix or an explicit
// id/uin/nin/identifier token β so testa_id qualifies but last_name does NOT.
So onBehalfOf may map to the identity's subjectRef, its DID, or any
identifier-named field (a national ID, testa_id, β¦) β but not a plain name.
The issuer is guided to set this at issuance (a JIT callout next to the onBehalfOf
field on the issue page).
6. Revocation β type-aware, uniform check
Each delegation credential gets its own revocation slot at issuance. statusRef
prefers the verifiably-hosted flat status (statusUri/statusIdx + a
statusType of bitstring|token) over a DPG-stamped credentialStatus β because
the flat list is the one we host and can dereference, while e.g. Inji's auth-code
Certify stamps an internal credentialStatus URL the verifier can't reach:
// FLAT status is preferred when present (revocable, publicly dereferenceable).
if uri := flatClaim(c, "statusUri"); uri != "" {
typ := "TokenStatusList"
if strings.Contains(strings.ToLower(flatClaim(c, "statusType")), "bitstring") {
typ = "BitstringStatusListEntry"
}
return StatusRef{Type: typ, URI: uri, Index: idx, ...}, true
}
// else credentialStatus (W3C bitstring) β¦ else status.status_list (SD-JWT token)
POST /api/v1/delegation/inji/revoke (and the issued-log Revoke button) flip the
right store: bitstring for W3C, token for SD-JWT.
7. The DPG / format matrix
Delegated access is proven end-to-end (issue β claim β AUTHORISED β revoke β
DENIED) across:
| DPG | W3C VCDM 1.1 | W3C VCDM 2.0 (ldp_vc) | SD-JWT |
|---|---|---|---|
| walt.id | β | β | β |
| Inji Certify Β· pre-auth | β | β | β |
| Inji Certify Β· auth-code | β | evaluates β (revocation Certify-managed) | β |
| CREDEBL | β | β | β (SD-JWT-only DPG) |
The headless conformant-proof holder (injiPreAuthClaim) + POST /api/v1/delegation/verify/sdjwt is the reusable holder for any OID4VCI pre-auth
issuer.
8. Verifier UX β the two-step pair picker
On Verifier β Verify, ticking "Verify as a delegated-access pair" turns the
credential grid into a two-step picker:
- Step 1 β the grid auto-filters to cards that have an
onBehalfOffield (the
delegations). Pick one. - Step 2 β the grid flips to the remaining cards (the identities). Pick the one
the delegation is about. - "β Pair selected" β Generate. Both picks live on the session and build the
OID4VP request (req.Templates = [subject, delegation]).
GenerateRequest builds each leg with the right type/format/vct via
buildTemplateForSchema, requesting only onBehalfOf for a W3C delegation (the
capability lives in termsOfUse, always disclosed) but all flat claims for an
SD-JWT one (selectively disclosed).
Single format only: walt.id v0.18.2 rejects a request whose descriptors have
different formats. Step 2 is therefore filtered to the same wire format as the
picked delegation (wireFormatOf:jwt_vc_jsonfor W3C,vc+sd-jwtfor SD-JWT).
The issuer is warned at issuance to keep both legs one format.
9. Holder consent β one card per requested credential
The holder's "Review what's being shared" parses every PD input-descriptor
(describePDCredentials) and renders a card per credential the verifier is asking
for β type, format, disclosure, the claims requested, and an in-your-wallet
indicator β so a pair shows as "The verifier is requesting 2 credentials" with a
card each, not one title with another's claims.
10. Known limits (walt.id v0.18.2 wallet)
The evaluator is correct; the wallet is the bottleneck for multi-credential
presentations:
- SD-JWT pairs can't be presented. Combining two SD-JWT credentials makes the
vpToken a JSON array the wallet-api can't serialize (JsonArray is not a JsonPrimitive). Use W3Cjwt_vc_jsonfor pairs. - W3C pairs work but require one wallet/DID. Both credentials must be claimed
under the same holder DID or invocation fails. - Mobile wallets often present only one descriptor of a multi-credential
request. Use the walt.id holder wallet for pairs.
Reliable recipe today: W3C jwt_vc_json, both credentials in one wallet/DID,
presented via the walt.id holder wallet.
11. API surface (delegated access)
| Method & path | Purpose |
|---|---|
POST /api/v1/delegation/issue |
issue a subject+delegation pair |
POST /api/v1/delegation/issue/bulk |
fan a pair out over many rows (async job) |
POST /api/v1/delegation/verify/request |
generate an OID4VP pair request |
GET /api/v1/delegation/verify/result/{state} |
the delegation verdict |
POST /api/v1/delegation/verify/sdjwt |
evaluate raw SD-JWT creds (headless) |
POST /api/v1/delegation/inji/setup |
provision an Inji auth-code pair |
POST /api/v1/delegation/inji/preauth/issue |
stage Inji pre-auth offers |
POST /api/v1/delegation/inji/preauth/claim |
headless conformant-proof claim |
POST /api/v1/delegation/inji/revoke |
revoke (type-aware: bitstring/token) |
Full request/response schemas are in the OpenAPI spec at /api/docs.