Skip to content
Registry StackDocsDevelopment (unreleased)

Explore SD-JWT VC locally

For the assertion provider and consumer or verifier

View as Markdown

Complete Get your first Evidence Gateway assertion before starting. You will reuse its local registry and adult-status project to explore the SD-JWT VC serialization without adding a wallet or a credential lifecycle.

Outcome
Two verified SD-JWT VC responses, a decoded view inspected only after verification, and a refused tampered credential.
Time
About 25 minutes
Level
Local development with synthetic data
Prerequisites
The completed first Evidence Gateway assertion tutorialIts adult-status project and registry.pyPython 3A shell with curl

Evidence Gateway can serialize one stateless Evidence response as signed JWS or SD-JWT VC. The question, authorization, source access, derivation, governed answer, audience, signing key, and audit boundary do not change.

Two separate authoring choices matter:

ChoiceAuthoring fieldResult
Permit the serializationresponseFormats: [signed-jws, sd-jwt-vc]The local bundle and the question’s local grant allow either signed response format.
Project a reviewed structuresdJwtVc on a reviewed-structured-value answerEach direct field becomes an independently encoded disclosure under the configured claim.

A scalar answer needs only the first choice. It becomes one root disclosure named by the governed concept URI. Omitting responseFormats keeps the project at signed JWS only.

In the terminal that owns the Python registry, return to the first-evidence-assertion directory and start it again:

Terminal window
python3 registry.py

Leave it running. In another terminal, enter the existing Evidence Gateway project:

Terminal window
cd adult-status

The first tutorial added this explicit format permission to questions/adult-status.yaml:

responseFormats: [signed-jws, sd-jwt-vc]

Signed JWS remains present and remains the default. The local authoring compiler applies the same closed list to the local bundle ceiling and this question’s local authority grant.

Start a fresh local generation:

Terminal window
evidencectl dev --detach
Evidence ready at http://127.0.0.1:8080
Mint ready at http://127.0.0.1:8081

Prepare a request that records SD-JWT VC as the expected response format before any response exists:

Terminal window
evidencectl request prepare adult-status \
--purpose age-check \
--subject person_id=person-123 \
--format sd-jwt-vc \
--name scalar-vc

Send it with the exact SD-JWT VC media type:

Terminal window
curl --silent --show-error --fail-with-body \
--config .evidence/requests/scalar-vc/authorization.curl \
--request POST \
--url http://127.0.0.1:8080/v1/evidence \
--header 'Content-Type: application/json' \
--header 'Accept: application/dc+sd-jwt' \
--data-binary @.evidence/requests/scalar-vc/request.json \
--output scalar.sd-jwt \
--write-out 'HTTP %{http_code}\n'
HTTP 200

Do not decode the compact response yet. Verify it against the expectations retained during request preparation:

Terminal window
evidencectl verify scalar.sd-jwt \
--context .evidence/requests/scalar-vc/verification.json \
--output scalar.verified.json
VERIFIED

The verified payload contains the same is_adult: true governed answer you saw in the signed JWS tutorial.

Inspect the compact structure after verification

Section titled “Inspect the compact structure after verification”

Now that verification succeeded, decode only enough of the stored credential to see its layout:

Terminal window
python3 - <<'PY'
import base64
import json
from pathlib import Path
compact = Path("scalar.sd-jwt").read_text().strip()
parts = compact.split("~")
def decode(value):
padded = value + "=" * (-len(value) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
header_segment, payload_segment, _ = parts[0].split(".")
print("typ:", decode(header_segment)["typ"])
print("vct:", decode(payload_segment)["vct"])
for disclosure in (part for part in parts[1:] if part):
decoded = decode(disclosure)
print("disclosure:", decoded[1])
PY
typ: dc+sd-jwt
vct: urn:registrystack:evidence:local:evidence-type:adult-status
disclosure: urn:registrystack:evidence:local:concept:adult-status:is_adult

The script deliberately does not print the disclosure salt or value. The one scalar governed value is one root disclosure. The compact response ends with a trailing ~ and has no key-binding JWT.

Read the local JWT VC Issuer Metadata and local signing-key set:

Terminal window
curl --silent --show-error --fail-with-body \
http://127.0.0.1:8080/.well-known/jwt-vc-issuer \
| python3 -m json.tool
curl --silent --show-error --fail-with-body \
http://127.0.0.1:8080/.well-known/evidence/jwks.json \
| python3 -m json.tool

These endpoints publish identity and public keys. They are discovery, not a trust decision. The prepared verification context already pins the expected issuer, audience, request nonce, subject binding, and trusted key material for this local request.

Change one encoded disclosure byte without touching the original credential:

Terminal window
python3 - <<'PY'
from pathlib import Path
parts = Path("scalar.sd-jwt").read_text().strip().split("~")
assert len(parts) >= 3 and parts[1]
parts[1] = ("A" if parts[1][0] != "A" else "B") + parts[1][1:]
Path("scalar-tampered.sd-jwt").write_text("~".join(parts) + "\n")
PY

Verification must fail and must not create trusted output:

Terminal window
if evidencectl verify scalar-tampered.sd-jwt \
--context .evidence/requests/scalar-vc/verification.json \
--output scalar-tampered.verified.json; then
printf 'Expected tampered credential refusal\n' >&2
exit 1
fi
test ! -e scalar-tampered.verified.json
printf 'Tampered credential refused\n'
Tampered credential refused

The disclosure digest is covered by the issuer signature. Altering the disclosure breaks the verified relationship between the signed digest and the disclosed value.

Next, add an illustrative reviewed structure containing the adult result and the criterion it was evaluated against. This is a second governed question, not a request-time option.

Create schemas/adult-assessment.yaml:

$schema: https://json-schema.org/draft/2020-12/schema
$id: urn:registrystack:evidence:local:schema:adult-assessment:v1
type: object
additionalProperties: false
required: [criterion, isAdult]
properties:
criterion:
type: string
const: at-least-18
isAdult:
type: boolean

Create questions/adult-assessment.yaml:

id: adult-assessment
question: What adult assessment applies to this person?
purpose: age-assessment-review
subject:
role: person
selector: person_id
source:
operation: getPerson
facts:
- name: date_of_birth
path: /date_of_birth
combine: exactly-one
collectionBounds: {}
answers:
- concept: adult_assessment
type: reviewed-structured-value
schema: schemas/adult-assessment.yaml
maximumSerializedBytes: 256
sdJwtVc:
claim: adultAssessment
disclosure: top-level
responseFormats: [signed-jws, sd-jwt-vc]
derivation: derivations/adult-assessment.rhai
disclosure:
allow: [adult_assessment]

adultAssessment is an authored claim name, not a built-in Evidence Gateway type. The schema is closed, and top-level makes the two direct fields independently encoded disclosures. A nested object would remain one atomic direct-field disclosure.

Create derivations/adult-assessment.rhai:

fn answer(facts, selectors, context) {
let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing"));
let adult_on = add_calendar_years(born, 18);
#{adult_assessment: #{
form: "reviewed-structured-value",
schema: "urn:registrystack:evidence:local:schema:adult-assessment:v1",
fields: #{
criterion: "at-least-18",
isAdult: compare_dates(context.legal_local_date, adult_on) >= 0
}
}}
}

Stop and clean the old immutable generation, then compile the updated project:

Terminal window
evidencectl dev stop
evidencectl dev clean
evidencectl dev --detach

Prepare the structured request:

Terminal window
evidencectl request prepare adult-assessment \
--purpose age-assessment-review \
--subject person_id=person-123 \
--format sd-jwt-vc \
--name structured-vc

Send it:

Terminal window
curl --silent --show-error --fail-with-body \
--config .evidence/requests/structured-vc/authorization.curl \
--request POST \
--url http://127.0.0.1:8080/v1/evidence \
--header 'Content-Type: application/json' \
--header 'Accept: application/dc+sd-jwt' \
--data-binary @.evidence/requests/structured-vc/request.json \
--output structured.sd-jwt \
--write-out 'HTTP %{http_code}\n'

Verify before inspection:

Terminal window
evidencectl verify structured.sd-jwt \
--context .evidence/requests/structured-vc/verification.json \
--output structured.verified.json
VERIFIED

Inspect only the names of the verified disclosures:

Terminal window
python3 - <<'PY'
import base64
import json
from pathlib import Path
parts = Path("structured.sd-jwt").read_text().strip().split("~")
names = []
for disclosure in (part for part in parts[1:] if part):
padded = disclosure + "=" * (-len(disclosure) % 4)
decoded = json.loads(base64.urlsafe_b64decode(padded))
names.append(decoded[1])
for name in sorted(names):
print("disclosure:", name)
PY
disclosure: criterion
disclosure: isAdult

Unlike the scalar root disclosure, the structured projection places the reviewed object under adultAssessment and gives each direct field its own digest and disclosure. Evidence Gateway’s current verifier validates the complete stored credential. This tutorial does not create a selectively disclosed wallet presentation.

Stop the services, inspect the last audit operation, and remove the generated local state:

Terminal window
evidencectl dev stop
evidencectl audit show --last-operation
evidencectl dev clean
Local Evidence stopped
ACCESS AUTHORIZED adult-assessment age-assessment-review requester=<pseudonym>
DISCLOSURE RELEASED adult_assessment
Removed stopped local Evidence state

Return to the registry terminal and press Ctrl+C.

The tutorial leaves the authored question, schema, derivation, request contexts, and signed responses in your working directory. Remove that directory with ordinary file commands when you no longer need it.

This local response has no OID4VCI offer or issuance session, status or revocation service, wallet onboarding, presentation exchange, or key-binding JWT. Its pseudonymous subject binding remains scoped to the request audience and purpose.

For a deployment, format permission belongs to the governed bundle and exact authority grant. Continue with Enable SD-JWT VC in a deployment for those two production gates and verifier trust requirements.