Skip to main content

How to Add a Custom SD-JWT Credential Type

This guide walks through the end-to-end process of defining a new verifiable credential type and integrating it with a SIROS ID issuer and verifier. It covers creating the credential type metadata, configuring issuance, and enabling verification.

The example throughout this guide uses a fictional Employee Badge credential issued by an organization to its employees.

Prerequisites

  • A GitHub account (for publishing credential type metadata)
  • Access to a SIROS ID issuer (hosted or self-hosted)
  • Access to a SIROS ID verifier (hosted or self-hosted)
  • A test wallet — open id.siros.org and create one if you haven't already

Overview

Adding a custom credential type involves three phases:

  1. Define the credential type by creating a VCTM (Verifiable Credential Type Metadata) file and publishing it
  2. Configure an issuer to construct and sign credentials of this type
  3. Configure a verifier to request and validate presentations of this type

Phase 1: Define the Credential Type (VCTM)

A VCTM file defines everything wallets and verifiers need to know about your credential type: the claims it contains, how it should be displayed, and which claims support selective disclosure.

Step 1: Author the Credential Definition

Create a markdown file describing your credential. For the Employee Badge example:

credentials/employee-badge.md
---
vct: https://example.com/credentials/employee-badge
background_color: "#1a365d"
text_color: "#ffffff"
---

# Employee Badge

An employee identification credential issued by an organization
to verify employment status and role.

## Claims

- `given_name` (string): Employee's given name [mandatory] [sd=always]
- `family_name` (string): Employee's family name [mandatory] [sd=always]
- `email` (string): Employee's work email address [mandatory] [sd=always]
- `employee_id` (string): Employee identifier [mandatory] [sd=always]
- `department` (string): Department name [sd=always]
- `role` (string): Job title or role [sd=always]
- `hire_date` (date): Date of hire [sd=always]

## Images

![Logo](images/logo.svg)

Key elements:

ElementPurpose
vct (front matter)Unique identifier for this credential type. Used in OID4VCI and OID4VP protocols.
background_color, text_colorDisplay hints for wallets rendering the credential card.
[mandatory]Marks a claim as required in every issued credential.
[sd=always]Enables selective disclosure — the holder can choose whether to share this claim.
Choosing a VCT Identifier

The vct value is a URI that uniquely identifies your credential type. Use a domain you control. For EU-regulated credentials, URN-based identifiers following the urn:eudi: scheme are conventional (e.g., urn:eudi:pid:arf-1.8:1).

You can also write a .vctm.json file directly instead of markdown (see Option B: Write VCTM JSON directly below).

Step 2: Publish to a Credential Type Registry

Once you have your credential definition, you need to publish it so issuers, verifiers, and wallets can discover it. There are two approaches:

registry.siros.org is the public SIROS Credential Type Registry. It is built automatically by registry-cli and hosted on GitHub Pages. GitHub is the write API — there are no PUT/DELETE endpoints. Instead, you publish credentials by pushing to a GitHub repository, and the registry discovers them automatically.

How registry.siros.org works:

  1. A sources.yaml file declares which GitHub repositories to scan
  2. Repositories tagged with the vctm GitHub topic are autodiscovered — no manual registration needed
  3. A GitHub Actions workflow runs registry-cli build every 6 hours, cloning all source repositories and detecting credential definitions
  4. The built site is deployed to GitHub Pages at https://registry.siros.org

To get your credential listed on registry.siros.org:

  1. Use the vctm-template — click "Use this template" to create your own repository
  2. Place your credential markdown file(s) in the credentials/ directory
  3. Push to the main branch — the included GitHub Action (mtcvctm) converts your markdown to .vctm.json on the vctm branch
  4. Tag your repository with the vctm GitHub topic so the registry autodiscovers it

After the next registry build cycle (up to 6 hours), your credential appears at:

https://registry.siros.org/<your-org>/<slug>.vctm.json

The registry also provides a TS11-compliant API with JWS-signed responses:

https://registry.siros.org/api/v1/schemas.json          # All schemas
https://registry.siros.org/api/v1/schemas/<id>.json # Individual schema
Authorization on registry.siros.org

Since GitHub is the write API, access control is handled through GitHub's standard mechanisms: repository permissions, branch protection rules, and pull request reviews. To add or update a credential, you push a commit. To remove one, you delete or untag the repository.

Option B: Run Your Own Registry (For Private or On-Premise Deployments)

If you need a private credential catalogue, want full control over the build pipeline, or operate in an air-gapped environment, you can run registry-cli yourself. A Docker image is published to ghcr.io/sirosfoundation/registry-cli for every release.

Key differences from registry.siros.org:

registry.siros.orgSelf-hosted registry
HostingGitHub Pages (static)You choose: any static file server, S3, Caddy, nginx, etc.
Source discoveryGitHub topic autodiscoveryAny mix of GitHub topics, explicit git URLs, or local directories
Build triggerGitHub Actions (every 6 hours)You decide: cron, CI/CD, manual
Access controlGitHub repository permissionsYour infrastructure's access controls
JWS signingPKCS#11 with production HSMDev keys, SoftHSM, or your own HSM
Credential sourcesPublic GitHub repositories onlyGit repos (public or private), local directories (file://)

Quick start with Docker Compose:

  1. Create a sources.yaml:
sources/sources.yaml
defaults:
branch: vctm

sources:
# Autodiscover from your GitHub org
- "github:topic/vctm?org=your-org"

# Explicit private repository
- "git:https://github.com/your-org/private-credentials.git"

# Local directory (useful for air-gapped environments)
- url: "file:///data/sources/local-creds"
organization: "MyOrg"
  1. Run with Docker Compose:
docker-compose.yml
services:
registry:
image: ghcr.io/sirosfoundation/registry-cli:latest
ports:
- "8080:8080"
volumes:
- ./sources:/data/sources:ro
- ./output:/data/output
environment:
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
docker compose up
# Open http://localhost:8080

Set GITHUB_TOKEN if your sources include GitHub topic searches or private repositories.

  1. For production, build the static site and deploy it to your web server:
docker run --rm \
-v ./sources:/data/sources:ro \
-v ./output:/data/output \
-e GITHUB_TOKEN="${GITHUB_TOKEN}" \
ghcr.io/sirosfoundation/registry-cli:latest \
registry-cli build \
--sources /data/sources/sources.yaml \
--output /data/output \
--base-url https://registry.your-org.com

Then serve the ./output directory with any static file server.

See the registry-cli documentation for full configuration options including JWS signing, SoftHSM setup, and TS11 compliance testing.

Option B: Write the VCTM JSON Directly

If you prefer not to use the markdown authoring workflow, you can write a VCTM JSON file directly following the SD-JWT VC Type Metadata specification. A minimal example:

employee-badge.vctm.json
{
"vct": "https://example.com/credentials/employee-badge",
"name": "Employee Badge",
"description": "Employee identification credential",
"display": [
{
"lang": "en",
"name": "Employee Badge",
"description": "Verifies employment status and role",
"rendering": {
"simple": {
"logo": {
"uri": "https://example.com/logo.svg",
"alt_text": "Example Corp"
},
"background_color": "#1a365d",
"text_color": "#ffffff"
}
}
}
],
"claims": [
{
"path": ["given_name"],
"display": [{"lang": "en", "label": "Given Name"}],
"sd": "always"
},
{
"path": ["family_name"],
"display": [{"lang": "en", "label": "Family Name"}],
"sd": "always"
},
{
"path": ["email"],
"display": [{"lang": "en", "label": "Email"}],
"sd": "always"
},
{
"path": ["employee_id"],
"display": [{"lang": "en", "label": "Employee ID"}],
"sd": "always"
},
{
"path": ["department"],
"display": [{"lang": "en", "label": "Department"}],
"sd": "always"
},
{
"path": ["role"],
"display": [{"lang": "en", "label": "Role"}],
"sd": "always"
},
{
"path": ["hire_date"],
"display": [{"lang": "en", "label": "Hire Date"}],
"sd": "always"
}
]
}

Host this file at a stable URL accessible to your issuer, or place it in a repository that your registry discovers. Both registry.siros.org and self-hosted registries detect .vctm.json files automatically.

Phase 2: Configure the Issuer

With the credential type defined, configure the SIROS ID issuer to construct and sign credentials of this type. The issuer needs to know:

  • Where to find the VCTM
  • How to authenticate users
  • How to map identity claims to credential claims

2.1 Place the VCTM File

Make the VCTM file available to the issuer. For Docker deployments, mount it into the container:

docker-compose.yml (snippet)
services:
issuer:
volumes:
- ./metadata/employee-badge.vctm.json:/metadata/vctm_employee_badge.json:ro

2.2 Add the Credential Constructor

Add an entry to the credential_constructor section of your issuer configuration. The auth_method determines how users authenticate before receiving the credential.

Using OIDC Authentication

If your organization has an OIDC identity provider (Keycloak, Azure AD, Okta, etc.):

config.yaml (snippet)
credential_constructor:
employee_badge:
vctm_file_path: "/metadata/vctm_employee_badge.json"
auth_method: oidc
format: "dc+sd-jwt"

apigw:
oidcrp:
enabled: true
client_id: "issuer-client"
client_secret: "${OIDC_CLIENT_SECRET}"
provider_metadata_url: "https://keycloak.example.com/realms/corp/.well-known/openid-configuration"
scopes:
- openid
- profile
- email
credential_config_id: "employee_badge"

The issuer maps OIDC claims from the ID token to credential claims automatically when claim names match (e.g., given_namegiven_name). For non-matching names, configure explicit mappings.

Using SAML Authentication

For organizations with SAML-based identity federations:

config.yaml (snippet)
credential_constructor:
employee_badge:
vctm_file_path: "/metadata/vctm_employee_badge.json"
auth_method: saml
format: "dc+sd-jwt"

apigw:
saml:
enabled: true
entity_id: "https://issuer.example.com/sp"
acs_endpoint: "https://issuer.example.com/saml/acs"
certificate_path: "/pki/sp-cert.pem"
private_key_path: "/pki/sp-key.pem"
credential_mappings:
- credential_config_id: "employee_badge"
entity_ids:
- "https://idp.example.com/idp"
attributes:
"urn:oid:2.5.4.42":
claim: "given_name"
required: true
"urn:oid:2.5.4.4":
claim: "family_name"
required: true
"urn:oid:0.9.2342.19200300.100.1.3":
claim: "email"
required: true

Using Pre-Authorized Code (API Integration)

For server-to-server issuance where your backend pushes credential data directly:

config.yaml (snippet)
credential_constructor:
employee_badge:
vctm_file_path: "/metadata/vctm_employee_badge.json"
auth_method: basic
format: "dc+sd-jwt"

issuer:
pre_authorized_code:
enabled: true
pin_required: false
code_ttl: 300

Then issue credentials using the REST API:

# 1. Create an identity mapping (if not already created)
curl -X POST https://issuer.example.com/api/v1/identity/mapping \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"authentic_source": "hr.example.org",
"authentic_source_person_id": "EMP-12345",
"attributes": {
"family_name": "Smith",
"given_name": "Alice",
"birth_date": "1990-05-15"
}
}'
# Response includes an "id" field — use it in the next step

# 2. Upload document data to the datastore
curl -X POST https://issuer.example.com/api/v1/datastore \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"meta": {
"authentic_source": "hr.example.org",
"scope": "employee-badge",
"document_id": "emp-badge-001"
},
"identity_mapping_ids": ["<mapping_id_from_step_1>"],
"document_data": {
"given_name": "Alice",
"family_name": "Smith",
"email": "alice.smith@example.com",
"employee_id": "EMP-12345",
"department": "Engineering",
"role": "Senior Developer",
"hire_date": "2023-03-15"
}
}'

Once uploaded, the credential offer is available at the issuer's /offers UI endpoint. Users can scan the QR code or follow the deep link to receive the credential in their wallet.

See API Integration for the full API reference.

2.3 Choosing an Auth Method

Auth MethodWhen to Use
oidcYour organization uses an OIDC identity provider (Keycloak, Azure AD, Okta)
samlYour organization participates in a SAML federation (eduGAIN, national eID)
basicYour backend system has verified user data and pushes it via API
openid4vpUsers must present an existing credential to prove eligibility

For the openid4vp method, you also specify which credential types and claims the user must present:

credential_constructor:
employee_badge:
vctm_file_path: "/metadata/vctm_employee_badge.json"
auth_method: openid4vp
auth_scopes: ["pid_1_8"]
auth_claims: ["given_name", "family_name", "birthdate"]
format: "dc+sd-jwt"

Phase 3: Configure the Verifier

The verifier needs to know which credential types it accepts and how to map credential claims into the OIDC ID tokens it produces for your application.

3.1 Register Credential Scopes

Map an OIDC scope to your credential type so applications can request it:

config.yaml (snippet)
verifier:
openid4vp:
supported_credentials:
- vct: "https://example.com/credentials/employee-badge"
scopes: ["employee"]

Applications then include employee in their OIDC scope parameter to trigger a presentation request for this credential.

3.2 Configure DCQL Queries (Optional)

For more granular control over which claims are requested, define a DCQL query:

presentation_request.yaml
credentials:
- id: employee_badge
format: vc+sd-jwt
meta:
vct_values:
- "https://example.com/credentials/employee-badge"
claims:
- path: ["given_name"]
- path: ["family_name"]
- path: ["employee_id"]
- path: ["department"]

3.3 Map Claims to OIDC ID Token

Configure how credential claims appear in the OIDC ID token returned to your application:

config.yaml (snippet)
verifier:
claim_mapping:
given_name: "$.vc.credentialSubject.given_name"
family_name: "$.vc.credentialSubject.family_name"
email: "$.vc.credentialSubject.email"
employee_id: "$.vc.credentialSubject.employee_id"
department: "$.vc.credentialSubject.department"

Your application then receives these claims in a standard OIDC ID token:

{
"iss": "https://verifier.example.org",
"sub": "pairwise-user-id",
"aud": "your-client-id",
"given_name": "Alice",
"family_name": "Smith",
"email": "alice.smith@example.com",
"employee_id": "EMP-12345",
"department": "Engineering"
}

3.4 Register the Verifier Client

If your application doesn't already have a client registration with the verifier, register one:

curl -X POST https://verifier.example.org/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Application",
"redirect_uris": ["https://my-app.example.com/callback"],
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"scope": "openid profile employee"
}'

Phase 4: Establish Trust

For the verifier to accept credentials from your issuer, the issuer must be trusted. SIROS ID supports several trust frameworks — choose the one that fits your deployment:

Trust FrameworkUse CaseDocumentation
URL WhitelistDevelopment and small deploymentsSimple list of trusted issuer URLs
ETSI Trust Status ListsEU-regulated environmentsTrust Infrastructure
Lists of Trusted Entities (LoTE)JSON-based trust listsLoTE Publishing
OpenID FederationDynamic, federated trustOpenID Federation

For development, a URL whitelist is the simplest approach. For production, use the trust framework required by your regulatory environment.

See Trust Services for detailed setup instructions.

Testing the Full Flow

1. Issue a Test Credential

  1. Open the SIROS ID Credential Manager (or any OID4VCI-compatible wallet)
  2. Navigate to Add Credential and select your issuer
  3. Authenticate with your identity provider
  4. Accept the credential — it should appear in your wallet with the display properties you defined in the VCTM

2. Verify the Credential

  1. In your application, trigger a login that uses the verifier as identity provider
  2. The verifier displays a QR code (or uses the W3C Digital Credentials API if supported by the browser)
  3. Scan the QR code with your wallet
  4. Review the claims being requested and approve
  5. Your application receives the mapped claims in the OIDC ID token

3. Verify Selective Disclosure

Test that selective disclosure works correctly:

  1. Configure the verifier to request only a subset of claims (e.g., given_name and department)
  2. Verify that the wallet only asks the user to share those specific claims
  3. Confirm the ID token contains only the requested claims

Troubleshooting

SymptomLikely CauseFix
Credential not appearing in walletVCTM not found or invalidCheck that the VCTM file is mounted correctly and valid JSON
Authentication fails during issuanceIdP misconfigurationVerify redirect URIs, client credentials, and scopes
Verifier rejects credentialIssuer not trustedAdd the issuer to the trust framework (see Phase 4)
Claims missing in ID tokenClaim mapping mismatchCheck claim_mapping paths against actual credential structure
Wallet shows raw claim namesMissing VCTM display metadataAdd display entries for each claim in the VCTM

Next Steps