Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.15.2.
Explore SD-JWT VC locally
For the assertion provider and consumer or verifier
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.
Understand the two choices
Section titled “Understand the two choices”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:
| Choice | Authoring field | Result |
|---|---|---|
| Permit the serialization | responseFormats: [signed-jws, sd-jwt-vc] | The local bundle and the question’s local grant allow either signed response format. |
| Project a reviewed structure | sdJwtVc on a reviewed-structured-value answer | Each 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.
Restart the registry
Section titled “Restart the registry”In the terminal that owns the Python registry, return to the first-evidence-assertion directory
and start it again:
python3 registry.pyLeave it running. In another terminal, enter the existing Evidence Gateway project:
cd adult-statusThe 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.
Request a scalar credential
Section titled “Request a scalar credential”Start a fresh local generation:
evidencectl dev --detachEvidence ready at http://127.0.0.1:8080Mint ready at http://127.0.0.1:8081Prepare a request that records SD-JWT VC as the expected response format before any response exists:
evidencectl request prepare adult-status \ --purpose age-check \ --subject person_id=person-123 \ --format sd-jwt-vc \ --name scalar-vcSend it with the exact SD-JWT VC media type:
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 200Do not decode the compact response yet. Verify it against the expectations retained during request preparation:
evidencectl verify scalar.sd-jwt \ --context .evidence/requests/scalar-vc/verification.json \ --output scalar.verified.jsonVERIFIEDThe 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:
python3 - <<'PY'import base64import jsonfrom 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])PYtyp: dc+sd-jwtvct: urn:registrystack:evidence:local:evidence-type:adult-statusdisclosure: urn:registrystack:evidence:local:concept:adult-status:is_adultThe 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.
Inspect issuer discovery
Section titled “Inspect issuer discovery”Read the local JWT VC Issuer Metadata and local signing-key set:
curl --silent --show-error --fail-with-body \ http://127.0.0.1:8080/.well-known/jwt-vc-issuer \ | python3 -m json.toolcurl --silent --show-error --fail-with-body \ http://127.0.0.1:8080/.well-known/evidence/jwks.json \ | python3 -m json.toolThese 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.
Prove tampering is refused
Section titled “Prove tampering is refused”Change one encoded disclosure byte without touching the original credential:
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")PYVerification must fail and must not create trusted output:
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 1fitest ! -e scalar-tampered.verified.jsonprintf 'Tampered credential refused\n'Tampered credential refusedThe disclosure digest is covered by the issuer signature. Altering the disclosure breaks the verified relationship between the signed digest and the disclosed value.
Model independently disclosed fields
Section titled “Model independently disclosed fields”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:v1type: objectadditionalProperties: falserequired: [criterion, isAdult]properties: criterion: type: string const: at-least-18 isAdult: type: booleanCreate questions/adult-assessment.yaml:
id: adult-assessmentquestion: What adult assessment applies to this person?purpose: age-assessment-reviewsubject: role: person selector: person_idsource: 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-levelresponseFormats: [signed-jws, sd-jwt-vc]derivation: derivations/adult-assessment.rhaidisclosure: 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:
evidencectl dev stopevidencectl dev cleanevidencectl dev --detachPrepare the structured request:
evidencectl request prepare adult-assessment \ --purpose age-assessment-review \ --subject person_id=person-123 \ --format sd-jwt-vc \ --name structured-vcSend it:
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:
evidencectl verify structured.sd-jwt \ --context .evidence/requests/structured-vc/verification.json \ --output structured.verified.jsonVERIFIEDInspect only the names of the verified disclosures:
python3 - <<'PY'import base64import jsonfrom 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)PYdisclosure: criteriondisclosure: isAdultUnlike 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.
Clean up
Section titled “Clean up”Stop the services, inspect the last audit operation, and remove the generated local state:
evidencectl dev stopevidencectl audit show --last-operationevidencectl dev cleanLocal Evidence stoppedACCESS AUTHORIZED adult-assessment age-assessment-review requester=<pseudonym>DISCLOSURE RELEASED adult_assessmentRemoved stopped local Evidence stateReturn 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.
Know the boundary
Section titled “Know the boundary”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.