Issuing SD-JWT VC Credentials via OID4VCI

This guide shows you how to issue an IETF SD-JWT VC credential using the walt.id Issuer2 API. You'll issue a credential from a ready-made profile in seconds, then see how to define your own.

SD-JWT VC: An IETF credential format supporting selective disclosure, letting holders reveal only specific claims of a credential rather than the whole thing.

OID4VCI: The protocol used to deliver the credential to a wallet.

Issuer2 issues credentials from profiles — reusable configurations declared ahead of time in a config file, with sensible defaults for the issuer key and credential data. If you're used to the original Issuer (v1), where the key and credential data were posted directly to an issuance endpoint every time, see Getting Started for what changed — you can still override a profile's defaults per-offer via runtimeOverrides.

Prerequisites

Before you begin, ensure you have:

  • Issuer2 API running — Follow the Setup guide
  • A test wallet — Use the walt.id Wallet for testing, or any OID4VCI-compatible wallet

Local Development: When running locally via Docker, the API is available at http://localhost:7002 (standalone) or http://localhost:7005 (Docker Compose). Use this as your base URL in the examples below.


Quick Start: Issue Using the Default Profile

The waltid-identity repository ships with a ready-made profile called identityCredentialSdJwt, bound to the identity_credential credential type. You can issue from it immediately — no configuration file editing required.

Step 1: Confirm the Profile Is Loaded

CURL

Endpoint: GET /issuer2/profiles/{profileId} | API Reference

Example Request
curl -X 'GET' \
  'http://localhost:7002/issuer2/profiles/identityCredentialSdJwt' \
  -H 'accept: application/json'

Example Response
{
  "profileId": "identityCredentialSdJwt",
  "name": "Identity Credential",
  "credentialConfigurationId": "identity_credential",
  "issuerKey": {
    "type": "jwk",
    "jwk": {
      "d": "KJ4k3Vcl5Sj9Mfq4rrNXBm2MoPoY3_Ak_PIR_EgsFhQ",
      "crv": "P-256",
      "x": "G0RINBiF-oQUD3d5DGnegQuXenI29JDaMGoMvioKRBM",
      "y": "ed3eFGs2pEtrp7vAZ7BLcbrUtpKkYWAT2JPUQK4lN4E",
      "kty": "EC"
    }
  },
  "issuerDid": "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IkcwUklOQmlGLW9RVUQzZDVER25lZ1F1WGVuSTI5SkRhTUdvTXZpb0tSQk0iLCJ5IjoiZWQzZUZHczJwRXRycDd2QVo3QkxjYnJVdHBLa1lXQVQySlBVUUs0bE40RSJ9",
  "credentialData": {
    "address": {
      "region": "Anystate",
      "street_address": "123 Main St",
      "country": "US",
      "locality": "Anytown"
    },
    "is_over_18": true,
    "phone_number": "+1-202-555-0101",
    "given_name": "John",
    "family_name": "Doe",
    "is_over_21": true,
    "is_over_65": true,
    "email": "johndoe@example.com",
    "birthdate": "1940-01-01"
  },
  "mapping": {
    "exp": "<timestamp-in-seconds:365d>",
    "nbf": "<timestamp-seconds>",
    "id": "<uuid>",
    "iat": "<timestamp-seconds>"
  },
  "selectiveDisclosure": {
    "fields": {
      "birthdate": {
        "sd": true
      },
      "family_name": {
        "sd": false
      }
    },
    "decoyMode": "NONE",
    "decoys": 0
  },
  "idTokenClaimsMapping": {
    "$.given_name": "$.given_name",
    "$.family_name": "$.family_name"
  }
}

The profile already has selective disclosure and issuer key/DID configured — you don't need to understand every field yet to issue your first credential. We'll come back to them in Defining Your Own Profile below.

Step 2: Create a Credential Offer

CURL

Endpoint: POST /issuer2/credential-offers | API Reference

Example Request
curl -X 'POST' \
  'http://localhost:7002/issuer2/credential-offers' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "profileId": "identityCredentialSdJwt",
    "authMethod": "PRE_AUTHORIZED"
  }'

Body Parameters

  • profileId: String (required) - The profile to issue from. Here, the shipped default identityCredentialSdJwt.
  • authMethod: String (required) - The OID4VCI flow used to claim the offer. PRE_AUTHORIZED requires no user login and is the simplest way to get started. See Using the Authorization Code Flow Instead for the alternative.

Example Response
{
  "offerId": "48f1dc54-134c-4e10-b889-17f3c0a595bf",
  "profileId": "identityCredentialSdJwt",
  "authMethod": "PRE_AUTHORIZED",
  "expiresAt": 1782898162344,
  "credentialOffer": "openid-credential-offer://?credential_offer_uri=http%3A%2F%2Flocalhost%3A7002%2Fopenid4vci%2Fcredential-offer%3Fid%3D48f1dc54-134c-4e10-b889-17f3c0a595bf"
}

Response Fields

  • offerId: String - Unique identifier for this offer.
  • expiresAt: Long - Unix timestamp (ms) when the offer expires (default: 300 seconds after creation).
  • credentialOffer: String - The OID4VCI credential offer URL. Turn this into a QR code or deep link for the wallet.

Claiming the Offer

  1. Generate a QR code from the credentialOffer URL, or use it directly as a deep link
  2. Open the walt.id Wallet and log in
  3. Click "Request Credential" and paste the offer URL into the field below the camera
  4. The wallet resolves the offer, requests the credential, and stores it

🎉 You've issued an SD-JWT VC credential using OID4VCI.

To learn how to accept this credential offer programmatically instead, see How to Accept SD-JWT VC Credentials via OID4VCI.


Defining Your Own Profile

To issue your own SD-JWT VC credential type instead of the shipped default, two config files need an entry, and the service needs a restart to pick them up.

Step 1: Declare the Credential Type

Add an entry to credential-issuer-metadata.conf:

credentialConfigurations = {
  "my_identity_credential" = {
    format = "dc+sd-jwt"
    scope = "my_identity_credential"
    vct = "vctBaseUrl"
    cryptographic_binding_methods_supported = ["jwk", "did:key", "did:web"]
    credential_signing_alg_values_supported = ["ES256"]
    proof_types_supported = {
      jwt = {
        proof_signing_alg_values_supported = ["ES256"]
      }
    }
  }
}

See Credential Issuer Metadata for the full field reference.

Step 2: Define a Profile

Add a matching profile to issuer2-profiles.conf:

profiles {
  myIdentityCredential {
    name = "My Identity Credential"
    credentialConfigurationId = "my_identity_credential"
    issuerKey = ${defaultIssuerKey}
    issuerDid = ${defaultIssuerDid}
    credentialData = {
      given_name = "Jane"
      family_name = "Smith"
    }
    mapping = {
      iat = "<timestamp-seconds>"
      nbf = "<timestamp-seconds>"
    }
    selectiveDisclosure = {
      fields = {
        family_name = { sd = true }
      }
    }
  }
}

See Issuer Profiles Configuration for every available profile field, including notifications and credential status.

Step 3: Restart and Verify

Profiles are loaded once, at startup. Restart the Issuer2 container after editing either config file, then confirm the new profile is loaded.

curl -X 'GET' 'http://localhost:7002/issuer2/profiles/myIdentityCredential'

Then repeat the two steps from Quick Start above, substituting your own profileId.


Using the Authorization Code Flow Instead

The example above used PRE_AUTHORIZED — no user login required. Issuer2 also supports the Authorization Code Flow, where the holder authenticates against an external IdP (e.g. Keycloak) before claiming the credential, and the resulting ID token claims can be mapped into the credential via idTokenClaimsMapping.

This requires registering an external OAuth/OIDC provider first. See Authorization Code Flow for the full sequence and setup.

Selective Disclosure

The default profile already marks birthdate as selectively disclosable. To configure which claims a holder can choose to hide, see Selective Disclosure in the profile reference.


Next Steps

The vct published for identity_credential is derived from your issuer's baseUrl (see VCT handling). When verifying, use the vct from your own /.well-known/openid-credential-issuer/openid4vci response rather than assuming a fixed value.

Last updated on July 1, 2026