Create an Issuer

You want to create an issuer and issue attestations to NL Wallet users.

An issuer (also known as a “verstrekkende voorziening” in Dutch) is essentially an entity that wants to issue attestations to the NL Wallet. An issuer issues cards to the wallet which attest to certain facts about a person. Things like diploma’s, driving licenses, personalia, etc.

This document provides a global outline of components used, the necessary decisions, data, and certificate(s), and guides the setup of a so-called issuer/verstrekkende-voorziening.

Open-source software

Did you know that the NL Wallet platform is fully open-source? You can find the project on GitHub.

What we’re going to cover

We’ll start with a paragraph about the related architecture, with links to the relevant architecture documents.

We’ll then cover the creation of the technical attestation schema, the creation of issuer and reader authentication documents and the corresponding reader and issuer certificates, which are essential for identifying your service within the NL Wallet ecosystem.

Next, we’ll guide you through setting up the issuance_server. This includes obtaining the software, configuring it (with an optional database backend), and running it for the first time.

We’ll also cover how to validate that your setup is working correctly, and finally, we’ll point you to the issuer API specifications.

Architecture overview

Our issuance implementation adheres to the OpenID4VCI specification.

For PID issuance, we have a specialized issuer called pid_issuer which interacts with DigiD through RDO Max, and which can obtain citizen data from RViG’s BRP. This document does not cover PID issuance, although many parts of this document do apply to PID issuance equally.

This document is about generic issuance, where the wallet can disclose certain attributes to an issuer in order to obtain new, different attestations, hence the name “disclosure-based issuance”.

You can find the general project start architecture documents for the NL Wallet on Pleio .These document the general platform architecture, solution architecture, design considerations and global functional design use cases.

To get a clear view on what issuance (in both its specialized PID issuance, and generic disclosure-based issuance form) looks like on the NL Wallet platform, have a look at the following documents:

Platform components overview

The NL Wallet platform consists of:

  • Issuers: (also known as Verstrekkende Voorzieningen), which can issue attested attributes, and which this document is mainly about;

  • Verifiers: (also known as Ontvangende Voorzieningen or Relying Parties), which can verify attested attributes they are interested in;

  • Backend: services that run in the NL Wallet datacenter(s) or cloud that facilitate various functions for the mobile app (usually not interacted with directly, by either Issuers or Verifiers);

  • App: the NL Wallet mobile app, which contains attested attributes, received from Issuers, and which it can disclose to Verifiers.

Issuers configure and maintain an issuance_server on their own premises or cloud environments, which they integrate with their own application, and which interacts with the NL Wallet app, in order to issue attested attributes.

Creating a technical attestation schema document

In the next two sub-sections we’ll dive into what’s needed to create a TAS document, i.e., a “technical attestation schema”. We’ll first decide on the required attributes for your TAS and then create an actual document. You’re free to change values mostly as you see fit, especially if you’ll be running this in a testing environment, but keep in mind that when this document is intended for a production environment, more stringent rules might apply.

When you work on eventual production readiness

If you plan to eventually bring your issuer into production readiness, you might want to consider our onboarding process. When you are a member of the NL Wallet community, you have access to community resources that can help with validation of your TAS, issuer_auth, reader_auth and issuance_server configuration files.

Decide on required metadata for your TAS

The TAS contains the following particularly important data elements:

ROOT

Key

Type

Description

vct

string

Verifiable credential type field

name

string

Readable name of this verifiable credential

description

string

Description of this verifiable credential

display

array

Array of display objects, one per language

claims

array

Array of claim objects

schema

object

A v2020-12 JSON Schema object defining the claims

Claims have the following elements:

CLAIMS

Key

Type

Description

path

array

An array of strings describing the claim path

display

array

Array of display objects, one per language

sd

string

Indicates whether a claim is selectively disclosable

svg_id

string

Template identifier for the SVG rendering metadata

Creating the technical attestation schema JSON document

Below you’ll see an example of a TAS JSON document. You can use it as an example for your own, or use it verbatim as-is to test disclosure-based issuance.

We’ll assume the latter for now; in any case, later sections in this guide will assume you’ll save it as target/is-config/insurance_metadata.json within the nl-wallet directory (i.e., where you cloned the git repository of the NL Wallet). If you change the name or location, keep in mind that the various script sections later on will have to be adjusted also).

Here is the example TAS for an insurance:

{
  "vct": "com.example.insurance",
  "name": "Insurance credential",
  "description": "Insurance credential",
  "display": [
    {
      "lang": "en-US",
      "name": "Insurance",
      "description": "An insurance credential",
      "summary": "{{coverage}}",
      "rendering": {
        "simple": {
          "background_color": "#b2e1ea",
          "text_color": "#152a62"
        }
      }
    },
    {
      "lang": "nl-NL",
      "name": "Verzekering",
      "description": "Een verzekering attestatie",
      "summary": "{{coverage}}",
      "rendering": {
        "simple": {
          "background_color": "#b2e1ea",
          "text_color": "#152a62"
        }
      }
    }
  ],
  "claims": [
    {
      "path": ["product"],
      "display": [
        {
          "lang": "nl-NL",
          "label": "Product",
          "description": "Soort verzekering"
        },
        {
          "lang": "en-US",
          "label": "Product",
          "description": "Type of insurance"
        }
      ],
      "sd": "always"
    },
    {
      "path": ["coverage"],
      "display": [
        {
          "lang": "nl-NL",
          "label": "Dekking",
          "description": "Dekking van de verzekering"
        },
        {
          "lang": "en-US",
          "label": "Coverage",
          "description": "Coverage of the insurance"
        }
      ],
      "sd": "always",
      "svg_id": "coverage"
    },
    {
      "path": ["start_date"],
      "display": [
        {
          "lang": "nl-NL",
          "label": "Startdatum",
          "description": "Datum waarop de verzekering ingaat"
        },
        {
          "lang": "en-US",
          "label": "Start date",
          "description": "Date on which the insurance starts"
        }
      ],
      "sd": "always"
    },
    {
      "path": ["duration"],
      "display": [
        {
          "lang": "nl-NL",
          "label": "Duur",
          "description": "Duur van de verzekering"
        },
        {
          "lang": "en-US",
          "label": "Duration",
          "description": "Duration of the insurance"
        }
      ],
      "sd": "always"
    },
    {
      "path": ["customer_number"],
      "display": [
        {
          "lang": "nl-NL",
          "label": "Klantnummer",
          "description": "Klantnummer van de verzekerde"
        },
        {
          "lang": "en-US",
          "label": "Customer number",
          "description": "Customer number of the insured"
        }
      ],
      "sd": "always"
    }
  ],
  "schema": {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "title": "Insurance VCT Schema",
    "description": "The JSON schema that defines a insurance VCT",
    "type": "object",
    "properties": {
      "vct": {
        "type": "string"
      },
      "vct#integrity": {
        "type": "string"
      },
      "iss": {
        "type": "string"
      },
      "nbf": {
        "type": "number"
      },
      "exp": {
        "type": "number"
      },
      "cnf": {
        "type": "object"
      },
      "status": {
        "type": "object"
      },
      "sub": {
        "type": "string"
      },
      "iat": {
        "type": "number"
      },
      "attestation_qualification": {
        "type": "string"
      },
      "product": {
        "type": "string"
      },
      "coverage": {
        "type": "string"
      },
      "start_date": {
        "type": "string",
        "format": "date"
      },
      "duration": {
        "type": "string"
      },
      "customer_number": {
        "type": "string"
      }
    },
    "required": ["vct", "iss", "attestation_qualification", "product", "coverage"]
  }
}

You can modify the above, keeping obvious constraints in mind (have a look at the previous section). Modified or not, make sure you save it somewhere, keeping our earlier warnings about file name and location in mind.

Creating an issuer authentication document

We’re first going to create a so-called issuer_auth document.

The subsections below describe the decisions you need to make as an issuer with regards to attributes you want to issue, what data we require from you, how to create an issuer certificate for your disclosure-based issuance setup (which is configured for usage within the issuance_server configuration).

In this guide, we assume you have onboarded succesfully - i.e., you are running your own CA and the public key of that CA has been shared with the operations team who will need to add your CA public key to the trust anchors of the app.

Onboarding optional

Do note that onboarding is not strictly necessary - you can follow all steps in this guide and observe things working in a local development environment - but when you want to test your issuer with the NL Wallet platform (i.e., our backend and mobile apps in our acceptance and pre-production environments), you do need to be onboarded to get access to those environments.

Decide on required metadata for your issuer_auth

An issuer certificate contains a bunch of metadata, which we store as a part of the certificate in a so-called X.509v3 extension. We use this data to present a view of you, the issuer, in the NL Wallet app GUI.

ROOT

Key

Languages

Description

organization.displayName

nl+en

Name of the verifier as shown in the app app.

organization.legalName

nl+en

Legal name of the verifier.

organization.description

nl+en

Short one-sentence description or mission statement of the verifier.

organization.webUrl

-

The home URL of the verifier.

organization.city

nl+en

The home city of the verifier.

organization.category

nl+en

Bank, Municipality, Trading, Delivery Service, etc.

organization.logo.mimeType

-

Logo mimetype, can be image/svg+xml, image/png or image/jpeg

organization.logo.imageData

-

Logo image data. When SVG, an escaped XML string, else base64

organization.countryCode

-

Two-letter country code of verifier residence.

organization.kvk

-

Chamber of commerce number of verifier.

organization.privacyPolicyUrl

-

Link to verifier’s privacy policy.

Note: In the Languages column where it says nl+en for example, please provide both Dutch and English values.

Creating the issuer_auth JSON document

When you’ve collected all the required metadata, you are ready to create the issuer_auth.json file. Here is an example for our insurance company:

{
  "organization": {
    "displayName": {
      "nl": "VerzekerAar",
      "en": "InsurAnce"
    },
    "legalName": {
      "nl": "VerzekerAar N.V.",
      "en": "VerzekerAar N.V."
    },
    "description": {
      "nl": "VerzekerAar is een voorbeeld-verzekeraar.",
      "en": "InsurAnce is an exemplar insurance company."
    },
    "webUrl": "https://insurance.example.com",
    "city": {
      "nl": "Den Haag",
      "en": "The Hague"
    },
    "category": {
      "nl": "Verzekeringen",
      "en": "Insurance"
    },
    "logo": {
      "mimeType": "image/svg+xml",
      "imageData": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" fill=\"none\"><rect width=\"64\" height=\"64\" y=\"-.002\" fill=\"#3A839A\" rx=\"12\"/><path fill=\"#FCFCFC\" d=\"M29.563 33.6H25.5v-4.8h4.063v-4h4.875v4H38.5v4.8h-4.062v4h-4.876zM32 16l-13 4.8v9.744C19 38.624 24.541 46.16 32 48c7.459-1.84 13-9.376 13-17.456V20.8zm9.75 14.544c0 6.4-4.144 12.32-9.75 14.128-5.606-1.808-9.75-7.712-9.75-14.128v-7.52l9.75-3.6 9.75 3.6z\"/></svg>"
    },
    "countryCode": "nl",
    "kvk": "99876543",
    "privacyPolicyUrl": "https://insurance.example.com/privacy"
  }
}

Take the above example, make sure you’ve read the previous sections which explain what the different key/values mean, and (optionally) construct your own issuer_auth.json file (or copy it verbatim if you’re just testing). When we are going to be creating the issuer certificate in the next sections, we are going to need it at a specific location, so save it (inside the nl-wallet git directory):

target/is-config/issuer_auth.json

Creating a reader authentication document

We’re going to create a so-called reader_auth document.

The subsections below describe the decisions you need to make as a verifier with regards to attributes you want to verify, what data we require from you, how to create a reader certificate for your usecase (which is configured for usage within the verification_server configuration).

In this guide, we assume you have onboarded succesfully - i.e., you are running your own CA and the public key of that CA has been shared with the operations team who will need to add your CA public key to the trust anchors of the app.

Onboarding optional

Do note that onboarding is not strictly necessary - you can follow all steps in this guide and observe things working in a local development environment - but when you want to test your verifier with the NL Wallet platform (i.e., our backend and mobile apps in our acceptance and pre-production environments), you do need to be onboarded to get access to those environments.

This chapter is also a part of creating a verifier

Note that when you’ve also created a verifier, this section will look familiar to you; that is because an issuer, like a verifier, needs a reader authentication document. This is because of how disclosure-based-issuance works: with disclosure-based-issuance, an issuer is essentially also a verifier (i.e., you disclose some attributes in order to obtain some new ones).

Decide on required metadata for your reader_auth

A reader certificate contains a bunch of metadata, which we store as a part of the certificate in a so-called X.509v3 extension. We use this data to know which attested attribute you want to verify, and to present a view of you, the verifier in the NL Wallet app GUI.

ROOT

Key

Languages

Description

purposeStatement

nl+en

For what purpose are you attesting? Login? Age verification? etc.

retentionPolicy

-

Do you have an intent to retain data? For how long?

sharingPolicy

-

Do you have an intent to share data? With whom?

deletionPolicy

-

Do you allow users to request deletion of their data, yes/no?

organization.displayName

nl+en

Name of the verifier as shown in the app app.

organization.legalName

nl+en

Legal name of the verifier.

organization.description

nl+en

Short one-sentence description or mission statement of the verifier.

organization.webUrl

-

The home URL of the verifier.

organization.city

nl+en

The home city of the verifier.

organization.category

nl+en

Bank, Municipality, Trading, Delivery Service, etc.

organization.logo.mimeType

-

Logo mimetype, can be image/svg+xml, image/png or image/jpeg

organization.logo.imageData

-

Logo image data. When SVG, an escaped XML string, else base64

organization.countryCode

-

Two-letter country code of verifier residence.

organization.kvk

-

Chamber of commerce number of verifier.

organization.privacyPolicyUrl

-

Link to verifier’s privacy policy.

authorizedAttributes

-

List of attributes you want to verify.

Note: In the Languages column where it says nl+en for example, please provide both Dutch and English values.

Decide on attributes you want to verify

You can verify any attribute (also known as a claim path) provided by any issuer on the plaform, but since we don’t have an issuer registry yet, you would need to know or otherwise get your hands on the JSON documents that define the claim paths that belong to a given vct (a Verifiable Credential Type).

For our own issuer(s), you can have a look at our supported authorized attributes document for an overview of claim paths you can use, and some background information on how the authorizedAttributes object works.

Creating the reader_auth JSON document

When you’ve collected all the required metadata, you are ready to create the reader_auth.json file. Here is an example for our insurance company:

{
  "purposeStatement": {
    "nl": "Uitgifte",
    "en": "Issuance"
  },
  "retentionPolicy": {
    "intentToRetain": true,
    "maxDurationInMinutes": 525600
  },
  "sharingPolicy": {
    "intentToShare": false
  },
  "deletionPolicy": {
    "deleteable": false
  },
  "organization": {
    "displayName": {
      "nl": "VerzekerAar",
      "en": "InsurAnce"
    },
    "legalName": {
      "nl": "VerzekerAargh N.V.",
      "en": "VerzekerAargh N.V."
    },
    "description": {
      "nl": "VerzekerAargh is een voorbeeld-verzekeraar.",
      "en": "InsurAnce is an exemplar insurance company."
    },
    "webUrl": "https://insurance.example.com",
    "city": {
      "nl": "Den Haag",
      "en": "The Hague"
    },
    "category": {
      "nl": "Verzekeringen",
      "en": "Insurance"
    },
    "logo": {
      "mimeType": "image/svg+xml",
      "imageData": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" fill=\"none\"><rect width=\"64\" height=\"64\" y=\"-.002\" fill=\"#3A839A\" rx=\"12\"/><path fill=\"#FCFCFC\" d=\"M29.563 33.6H25.5v-4.8h4.063v-4h4.875v4H38.5v4.8h-4.062v4h-4.876zM32 16l-13 4.8v9.744C19 38.624 24.541 46.16 32 48c7.459-1.84 13-9.376 13-17.456V20.8zm9.75 14.544c0 6.4-4.144 12.32-9.75 14.128-5.606-1.808-9.75-7.712-9.75-14.128v-7.52l9.75-3.6 9.75 3.6z\"/></svg>"
    },
    "countryCode": "nl",
    "kvk": "99876543",
    "privacyPolicyUrl": "https://insurance.example.com/privacy"
  },
  "requestOriginBaseUrl": "https://insurance.example.com",
  "authorizedAttributes": {
    "urn:eudi:pid:nl:1": [
      ["urn:eudi:pid:nl:1", "bsn"],
      ["bsn"]
    ]
  }
}

Take the above example, make sure you’ve read the previous sections which explain what the different key/values mean, and (optionally) construct your own reader_auth.json file (or copy it verbatim if you’re just testing). When we are going to be creating the reader certificate in the next sections, we are going to need it at a specific location, so save it (inside the nl-wallet git directory):

target/is-config/reader_auth.json

Creating issuer, reader and tsl certificates

Let’s create the issuer, reader and tsl certificates. We’re going to clone the NL Wallet repository, enter its directory, set a target directory and specify an identifier (this identifies your organization, and should be in lowercase characters a-z, can end with numbers but may not begin with them).

We then make sure the target directory exists, and invoke cargo (rust’s build tool) to in turn invoke wallet_ca which will create the issuer, reader and tsl certificates and keys.

Finally, we invoke openssl to convert our PEM certificates and key into DER format.

Do you have a working toolchain?

Make sure you have a working toolchain as documented in our GitHub project root README.md here. Specifically, you need to have rust and openssl installed and working.

Did you create an issuer_auth.json and reader_auth.json?

You need valid issuer_auth.json and reader_auth.json, documents, which you should have, if you followed along with the previous sections where we created issuer and reader authorization documents.

Did you create your own CA?

You need a CA certificate and key. By default, when you’re running locally, the setup-devenv.sh script will have created these for you. You can also opt to create your own custom self-signed CA certificate and key, which is documented in the Create a CA document, and which is required if you need to participate in the NL Wallet community platform.

Do you intend to test your issuer on the NL Wallet platform?

You can test your issuer locally (more or less exactly like we do with our demo-issuer app) for which you don’t need anything except the code in our git repository. But if you want to test your issuer with the NL Wallet platform (i.e., the NL Wallet apps on our Test Flight and Play Store Beta environments plus backends), you will need to have succesfully completed the onboarding process.

# Git clone and enter the nl-wallet repository if you haven't already done so.
git clone https://github.com/MinBZK/nl-wallet
cd nl-wallet

# Set and create target directory, identifier for your certificates.
export CA_DIR=target/ca-cert
export TARGET_DIR=target/vs-config
export IDENTIFIER=foocorp
mkdir -p "${CA_DIR}" "${TARGET_DIR}"

# Create the issuer certificate using wallet_ca.
cargo run --manifest-path "wallet_core/Cargo.toml" --bin "wallet_ca" issuer \
    --ca-key-file "${CA_DIR}/ca.${IDENTIFIER}.key.pem" \
    --ca-crt-file "${CA_DIR}/ca.${IDENTIFIER}.crt.pem" \
    --common-name "issuer.${IDENTIFIER}" \
    --issuer-auth-file "${TARGET_DIR}/issuer_auth.json" \
    --file-prefix "${TARGET_DIR}/issuer.${IDENTIFIER}"

# Create the reader certificate using wallet_ca.
cargo run --manifest-path "wallet_core/Cargo.toml" --bin "wallet_ca" reader \
    --ca-key-file "${CA_DIR}/ca.${IDENTIFIER}.key.pem" \
    --ca-crt-file "${CA_DIR}/ca.${IDENTIFIER}.crt.pem" \
    --common-name "reader.${IDENTIFIER}" \
    --reader-auth-file "${TARGET_DIR}/reader_auth.json" \
    --file-prefix "${TARGET_DIR}/reader.${IDENTIFIER}"

# Create the tsl certificate using wallet_ca.
cargo run --manifest-path "wallet_core/Cargo.toml" --bin "wallet_ca" tsl \
        --ca-key-file "${CA_DIR}/ca.issuer.key.pem" \
        --ca-crt-file "${CA_DIR}/ca.issuer.crt.pem" \
        --common-name "tsl.${IDENTIFIER}" \
        --file-prefix "${TARGET_DIR}/tsl.${IDENTIFIER}"

# Convert certificates PEM to DER.
openssl x509 \
    -in "${TARGET_DIR}/issuer.${IDENTIFIER}.crt.pem" -inform PEM \
    -out "${TARGET_DIR}/issuer.${IDENTIFIER}.crt.der" -outform DER
openssl x509 \
    -in "${TARGET_DIR}/reader.${IDENTIFIER}.crt.pem" -inform PEM \
    -out "${TARGET_DIR}/reader.${IDENTIFIER}.crt.der" -outform DER
openssl x509 \
    -in "${TARGET_DIR}/tsl.${IDENTIFIER}.crt.pem" -inform PEM \
    -out "${TARGET_DIR}/tsl.${IDENTIFIER}.crt.der" -outform DER

# Convert keys PEM to DER.
openssl pkcs8 -topk8 -nocrypt \
    -in "${TARGET_DIR}/issuer.${IDENTIFIER}.key.pem" -inform PEM \
    -out "${TARGET_DIR}/issuer.${IDENTIFIER}.key.der" -outform DER
openssl pkcs8 -topk8 -nocrypt \
    -in "${TARGET_DIR}/reader.${IDENTIFIER}.key.pem" -inform PEM \
    -out "${TARGET_DIR}/reader.${IDENTIFIER}.key.der" -outform DER
openssl pkcs8 -topk8 -nocrypt \
    -in "${TARGET_DIR}/tsl.${IDENTIFIER}.key.pem" -inform PEM \
    -out "${TARGET_DIR}/tsl.${IDENTIFIER}.key.der" -outform DER

The used CA public certificate (referenced in the previous wallet_ca command) needs to be in the list of various so-called trust anchors. Specifically, issuers and verifiers, and the NL Wallet app itself need to know if this CA is a trusted CA, and our software “knows” that by checking its trust anchors.

When you run locally, when using setup-devenv.sh and start-devenv.sh, the generated CA certificate is automatically added to the trust anchors within the configuration files of pid_issuer, demo_issuer, verification_server, issuance_server, demo_relying_party and the NL Wallet app config.

When you create your own CA, you need to make sure the public key of your CA is in the relevant trust anchor configuration settings. When you are a member of the NL Wallet community, and so using NL Wallet managed backend services and mobile apps, this is done for you (i.e., you just need to sign your issuer certificate with your CA, which the wallet_ca utility invocation above did for you, and during the NL Wallet community onboarding process you shared your CA certificate with the operations team who ensure your CA is in the various trust anchor lists).

When you run locally, but with a manually created CA, you need to add the CA public certificate to your services and wallet app config yourself. When we generate the configuration later in this guide, we will do this automatically, provided you used the naming conventions we used in the previous wallet_ca invocations.

Issuance server setup

In the following sections, we’ll guide you through obtaining the software, setting up a database backend and creating the issuance_server configuration file.

Obtaining the software

The issuance_server binary can be obtained by downloading a pre-compiled binary from our releases page, or by compiling from source. To compile from source, make sure you have our git repository checked out and make sure you’ve configured your local development environment. Then:

cd nl-wallet
cargo build \
  --manifest-path wallet_core/Cargo.toml \
  --package issuance_server \
  --bin issuance_server \
  --locked --release

The above command creates wallet_core/target/release/issuance_server, which is a release binary for the platform you’re running on. Let’s copy that binary to our target config directory for usage later:

mkdir -p target/is-config
cp wallet_core/target/release/issuance_server target/is-config

About default feature flags

Note that since we don’t specify a --features argument in the above cargo command, the default feature flags apply. For issuance_server, this happens to be just postgres. When you build for local development, the build script enables another feature flag called allow_insecure_url, which would allow you to talk to an issuance_server using an (insecure) http:// URL.

Don’t allow insecure URLs on production-like environments

Don’t enable allow_insecure_url on anything remotely production-like. Doing so anyway, accidentally or not, could expose you to man-in-the-middle attacks.

Using a database backend

The issuance_server needs a PostgreSQL database for maintaining status lists. When compiled with the postgres feature flag (which is the default), it can also use a PostgreSQL database backend to store state. This is done by configuring a postgres:// storage URL in the issuance_server.toml configuration file. In this section, we’ll create a PostgreSQL database, configure credentials and configure the schema (tables, columns).

You can also run with a memory session store

Note that you can run issuance_server with a storage URL memory:// This makes the server store session state in memory. When using in-memory session state, on server shutdown or crash, any session state will be lost.

Setting up PostgreSQL

You might already have a PostgreSQL database running, in which case you need the credentials of a database user with createdb and createrole role attributes, and the hostname of the system running the PostgreSQL database (can be localhost or any fully-qualified domain name).

When you don’t have a PostgreSQL database service running, you can create one following the installation instructions or you can use something like docker to run a containerized PostgreSQL service, which we’ll document here.

Use correct credentials and hostname in commands below

When you decide to use your own previously configured PostgreSQL database service, make sure you don’t execute the docker run command which creates a new PostgreSQL database service, and make sure you use the correct hostname, username an password values.

# Specify database hostname, superuser name and password for PostgreSQL itself
# (change these if you're using you own previously created database service):
export PGHOST=localhost
export PGPORT=5432
export PGUSER=postgres
export PGPASSWORD="$(openssl rand -base64 12)"

# Specify database hostname, issuance_server database name, user name and
# password for verification_server:
export DB_NAME=issuance_server
export DB_USERNAME=wallet
export DB_PASSWORD="$(openssl rand -base64 12)"

Let’s use Docker to run PostgreSQL, using a volume named postgres for the database storage. We’ll run in the background (the --detach option) and auto-clean up the running container after stop (--rm). We’re using the previously declared environment variables for hostname, user and password values:

# Run a Docker image named postgres, set superuser password to $PGPASSWORD.
docker run --name postgres --volume postgres:/var/lib/postgresql/data \
--rm  --detach --publish $PGPORT:5432 --env POSTGRES_PASSWORD="$PGPASSWORD" postgres

The next sections will use the environment variables declared previously (and whichever database they point to).

Create user and database

Next, we’ll create a user for the database and the database itself:

# Below psql commands use PGHOST, PGPORT, PGUSER and PGPASSWORD to execute.
psql -c "create user $DB_USERNAME with password '$DB_PASSWORD';"
psql -c "create database $DB_NAME owner $DB_USERNAME;"

Apply database schema

To initialize the issuance_server database schema, we will utilize the migration tool helper:

Applying the database schema using fresh is destructive

Applying the issuance_server database schema using the fresh argument is destructive! Any tables are cleared, data will be destroyed. Be sure you don’t run this on a currently operational production copy of issuance_server. The migration tool also supports an ostensibly non-destructive argument up which would not re-initialize the entire database, but as of this writing (2025-10-26) we don’t yet guarantee that our database initialization scripts are non-changing, and hence, up might not work as intended.

cd nl-wallet
DATABASE_URL="postgres://$DB_USERNAME:$DB_PASSWORD@$PGHOST:$PGPORT/$DB_NAME" \
cargo run \
  --manifest-path wallet_core/wallet_server/issuance_server/Cargo.toml \
  --package issuance_server_migrations \
  --bin issuance_server_migrations -- fresh

You can show the configuration by issuing the following (might be a good idea to keep this safe somewhere):

echo -e "\npostgres.host: '$PGHOST'\npostgres.port: '$PGPORT'\npostgres.user: '$PGUSER'\npostgres.pass: '$PGPASSWORD'\ndatabase.name: '$DB_NAME'\ndatabase.user: '$DB_USERNAME'\ndatabase.pass: '$DB_PASSWORD'\n"

Creating a configuration

In the following sections we’ll create the parts which will make up the issuance_server.toml configuration file. Sections marked as “(optional)” can be skipped. In the [final section]((#writing-the-configuration-file) we assemble the actual issuance_server.toml file.

Example configuration file

For reference, we have an annotated example configuration file which you can check for the various settings you can configure. We cover most (all?) of them here.

Similarity to verification_server

If you’ve also created a verifier, this part of the documentation will look familiar, if slightly different here and there. This is in part because the configuration of an issuance_server relies for a significant part on the shared wallet_server code, which a verification_server also relies upon.

Logging settings (optional)

To configure request logging and specify if we want the log output in the JSON format, we set the following:

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/01-logging-settings.toml"
log_requests = true
structured_logging = false
EOF

Optional runtime logging using env_logger

In addition to the above, the NL Wallet uses env_logger, which means you can use the RUST_LOG environment variable when running issuance_server later on. For example, to run with debug log output, you can prefix the command with the RUST_LOG environment variable: RUST_LOG=debug ./issuance_server

Configuring trust anchors

When you created the issuer, reader and tsl certificates, you signed those certificates using a CA, either generated by the development setup script or specifically created by you as part of the (optional) community onboarding process.

The issuance_server distinguishes two kinds of trust anchors:

  • issuer_trust_anchors - a string array of CA certificates which are considered trusted to sign issuer certificates, in DER format, base64 encoded;

  • reader_trust_anchors - a string array of CA certificates which are considered trusted to sign reader certificates, in DER format, base64 encoded;

The trust anchor arrays tell the issuance_server which certificates it can trust. If an issuance_server is presented with certificates signed by a CA that is not in its trust anchor arrays, operations will fail (by design).

We need to trust our own CA, whether it is created by the development setup scripts or explicitly by you. The development scripts create a separate CA for issuers and readers (usually at scripts/devenv/target/ca.issuer.crt.der and scripts/devenv/target/ca.reader.crt.der). When you create and use your own CA for community development purposes as documented here, you can use that CA generally for signing both issuance and reader certificates, and hence, add it to both the issuer and reader trust anchors.

The below code block will initialize the issuer and reader trust anchor environment variables with the CA certificates it can find, both generated by development scripts and any you created yourself, provided you followed the CA creation instructions to the letter and used the naming convention documented there which means you would have a target/ca-cert directory with your CA certificates in DER format there. The code block assumes you have the nl-wallet git repository checked out.

cd nl-wallet
export IS_ISSUER_TRUST_ANCHORS=()
export IS_READER_TRUST_ANCHORS=()
for i in scripts/devenv/target/ca.issuer.crt.der target/ca-cert/ca.*.crt.der; do \
    [[ -f $i ]] && IS_ISSUER_TRUST_ANCHORS+=($(openssl base64 -e -A -in $i)); done
for r in scripts/devenv/target/ca.reader.crt.der target/ca-cert/ca.*.crt.der; do \
    [[ -f $r ]] && IS_READER_TRUST_ANCHORS+=($(openssl base64 -e -A -in $r)); done

export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/03-trust-anchors.toml"
issuer_trust_anchors = [$(printf '"%s",' "${IS_ISSUER_TRUST_ANCHORS[@]}" | sed 's/,$//')]
reader_trust_anchors = [$(printf '"%s",' "${IS_READER_TRUST_ANCHORS[@]}" | sed 's/,$//')]
EOF
unset IS_ISSUER_TRUST_ANCHORS IS_READER_TRUST_ANCHORS

Determine public URL

The public_url is the URL that is used by the NL Wallet app to reach the address and port of the issuance_server:

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/04-public-url.toml"
public_url = "https://issuer.example.com/"
EOF

Use a valid domain name here

In the above, we use issuer.example.com as the fully-qualified domain name. Technically, this domain needs not be world-reachable, but it does need to DNS resolve for the NL Wallet app and the issuance_server. Make sure you use a domain that is yours and that you control.

A note about allowed public URL schemes

When you built or otherwise obtained the software, you did not specify the allow_insecure_url feature flag. This means you cannot specify an http:// URL here, and need to specify an https:// URL.

Configuring allowed client IDs

You can restrict which NL Wallet apps are accepted by the issuance_server by configuring a wallet_client_ids array. The entries of this array would contain the client_id value of a wallet implementation. This allows you to allow-list groups of wallet apps based on their client_id value. For example, for allowing apps that have https://wallet.edi.rijksoverheid.nl configured as client_id:

cd nl-wallet
export IS_WALLET_CLIENT_IDS=("https://wallet.edi.rijksoverheid.nl")
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/06-wallet-client-ids.toml"
wallet_client_ids = [$(printf '"%s",' "${IS_WALLET_CLIENT_IDS[@]}" | sed 's/,$//')]
EOF
unset IS_WALLET_CLIENT_IDS

Configuring metadata document references

We previously made a technical attestation schema JSON document. The issuance_server needs to know about these schemas. We can tell the server about available schemas through the metadata setting. In this section, we’re going to reference the previously created JSON document insurance_metadata.json, which, if you followed the instructions, was copied to target/is-config within the nl-wallet directory, where the issuance_server will find it using the below configuration (provided it is started from the target/is-config directory):

cd nl-wallet
export IS_WALLET_METADATA_FILES=("insurance_metadata.json")
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/08-wallet-metadata-files.toml"
metadata = [$(printf '"%s",' "${IS_WALLET_METADATA_FILES[@]}" | sed 's/,$//')]
EOF
unset IS_WALLET_METADATA_FILES

Configuring listener address and port

The server can be configured to listen on a single IP address and port. The address needs to be reachable by the NL Wallet mobile app:

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/09-listener-addresses-and-ports.toml"

[wallet_server]
ip = "0.0.0.0"
port = 8001
EOF

Configure a correct IP address

In the above configuration settings, we set 0.0.0.0 as the address, which means the server binds to all network interfaces, on the specified port. This might be fine or it might not be in your specific case. If you need the server to bind to a specific IP address, specify that instead of 0.0.0.0.

The storage settings (optional)

The default storage settings URL is memory://, which causes the server to store session state in-memory, which is ephemeral - i.e., on server crash or shutdown, any existing session state is lost. If you don’t have a [storage] section in your configuration, then memory:// is used.

Using in-memory session state
cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/10-storage-settings.toml"

[storage]
url = "memory://"
EOF
Using database persisted session state
cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/10-storage-settings.toml"

[storage]
url = "postgres://$DB_USERNAME:$DB_PASSWORD@$PGHOST:$PGPORT/$DB_NAME"
EOF

Make sure database setting environment variables are set

When you use the postgres:// URL, you tell the server to store session state in a PostgreSQL database. In the above, we assume you still have the environment variables configured like we documented in the database configuration section (i.e., Using a database backend).

Configuring a hardware security module (optional)

You can opt to use a hardware security module (HSM) to store private keys for disclosure_settings and attestation_settings. To do so we need to configure a few things:

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/11-hardware-security-module.toml"

[hsm]
library_path = "/path/to/some/pkcs11-compatible/library.so"
user_pin = "12345678"
max_sessions = 3
max_session_lifetime_in_sec = 900
EOF

Make sure you specify a correct library path

The HSM functionality depends on a PKCS#11 compatible shared library which will have been provided by your HSM vendor. Technically you can also use any PKCS#11 implementation here. For development purposes we test with the softhsm2 library, which is usually called something like libsofthsm2.so (the path location and filename extension differs per operating system and/or packaging environment).

Private key field needs to be a key label when using HSM type

When using a hardware security module, the private_key field of disclosure_settings and/or attestation_settings need to be the HSM key label.

It is possible to use both hardware and software private keys in the same issuance_server instance. Simply make sure you set private_key_type to hsm for HSM managed keys and to software when using base64 encoded DER strings in the private_key field.

Configuring the status list settings (optional)

The issuance_server maintains binary format token status lists which are used for revocation and validity checking. We’ll set the defaults here:

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/12-status-lists.toml"

[status_lists]
list_size = 100_000
create_threshold_ratio = 0.1
expiry_in_hours = 24
refresh_threshold_ratio = 0.25
serve = true
EOF

By default the issuance server will serve the status lists it creates. You can opt out of this behaviour and serve the status lists yourself. Ensure that you map a request with URL path ending on /id to filesystem path of with name id.jwt.

By default the status lists uses the same database as configured for the session store. If you have a memory store as session store you need to configure a PostgreSQL storage_url under the [status_lists] block. You can also configure a storage_url under the [status_lists] block if you want to store the status list in a different database or use a different user.

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/12-status-lists.toml"

[status_lists]
url = "postgres://$DB_USERNAME:$DB_PASSWORD@$PGHOST:$PGPORT/$DB_NAME"
EOF

Configuring disclosure-based issuance elements

We’re now going to configure the configuration blocks that together make up a disclosure-based issuance element. We’re going to call our insurance elements as follows:

  • [disclosure_settings.insurance]

  • [[disclosure_settings.insurance.dcql_query.credentials]]

  • [disclosure_settings.insurance.attestation_url_config]

  • [attestation_settings.insurance]

In the next sub-sections we’ll cover each one of these.

The disclosure settings

We’re going to base64 encode the reader key and certificate within the private_key and certificate fields of the disclosure_settings. This is the certificate that embedded the previously created reader_auth.json. Let’s create the section:

cd nl-wallet
export BASE64="openssl base64 -e -A"
export IDENTIFIER="foocorp"
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/13-disclosure-settings.toml"

[disclosure_settings.insurance]
private_key_type = "software"
private_key = "$(< "${TARGET_DIR}/reader.${IDENTIFIER}.key.der" $BASE64)"
certificate = "$(< "${TARGET_DIR}/reader.${IDENTIFIER}.crt.der" $BASE64)"
EOF
unset BASE64 IDENTIFIER
The DCQL query credentials

Add the DCQL query credentials (note, the double brackets, i.e., [[, and ]], are intentional):

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/14-dcql-query-credentials.toml"

[[disclosure_settings.insurance.dcql_query.credentials]]
id = "insurance_credential"
format = "dc+sd-jwt"
meta = { vct_values = ["urn:eudi:pid:nl:1"] }
claims = [ { path = ["urn:eudi:pid:nl:1", "bsn"], intent_to_retain = false } ]
EOF
The attestation URL configuration

The attestation URL configuration section configures where the issuance_server is expected to fetch its attestable attributes from. The base_url setting points to the attestation server (when you run in a local development environment, this is the demo_issuer, running on port 3006):

cd nl-wallet
export TRUST_ANCHORS=()
for i in scripts/devenv/target/demo_issuer/ca.crt.der target/ca-cert/ca.*.crt.der; do \
    [[ -f $i ]] && TRUST_ANCHORS+=($(openssl base64 -e -A -in $i)); done

export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/15-attestation-url-config.toml"

[disclosure_settings.insurance.attestation_url_config]
base_url = "https://your-attestation-server.example.com/insurance"
trust_anchors = [$(printf '"%s",' "${TRUST_ANCHORS[@]}" | sed 's/,$//')]
EOF
unset TRUST_ANCHORS
The attestation settings

We’re now going to base64 encode the issuer key and certificate within the private_key and certificate fields of the attestation_settings. This is the certificate that embedded the previously created issuer_auth.json. Let’s create the section:

cd nl-wallet
export BASE64="openssl base64 -e -A"
export IDENTIFIER="foocorp"
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/16-attestation-settings.toml"

[attestation_settings.insurance]
valid_days = 365
copies_per_format = { "mso_mdoc" = 4, "dc+sd-jwt" = 4 }
private_key_type = "software"
private_key = "$(< "${TARGET_DIR}/issuer.${IDENTIFIER}.key.der" $BASE64)"
certificate = "$(< "${TARGET_DIR}/issuer.${IDENTIFIER}.crt.der" $BASE64)"
EOF
The attestation settings associated status list settings

Next to the previously done [status_lists] settings, which control how big and when status lists are created, an attestation_settings associated status_list block.

This block configures the context_path, which together with a base_url (if empty the public_url of the issuance server is used) is added to the attestation as a location for the Status List Claim. This url needs to be publicly reachable by the NL Wallet app and the verifiers.

Next to the location a publish_dir needs to be configured where the status lists need to be published. This can be served via either a separate static file server or via the issuance server.

Additionally, the private_key settings are used to sign the Status Token List, which the wallet and the verification_server validate when interacting with Status Token Lists. The service pointed to by base_url needs to be publicly reachable by the NL Wallet app and the verifiers. Let’s create it:

cd nl-wallet
export BASE64="openssl base64 -e -A"
export IDENTIFIER="foocorp"
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/17-attestation-status-list.toml"

[attestation_settings.insurance.status_list]
context_path = "/tsl"
publish_dir = "/srv/html/tsl"
private_key_type = "software"
private_key = "$(< "${TARGET_DIR}/tsl.${IDENTIFIER}.key.der" $BASE64)"
certificate = "$(< "${TARGET_DIR}/tsl.${IDENTIFIER}.crt.der" $BASE64)"
EOF

Writing the configuration file

In the previous sections, you’ve created a bunch of partial configuration blocks which we will use in this section to generate our issuance_server.toml configuration file. To generate our configuration file, issue the following command:

cd nl-wallet
export TARGET_DIR=target/is-config && mkdir -p "$TARGET_DIR/parts"
cat "$TARGET_DIR"/parts/*.toml > "$TARGET_DIR/issuance_server.toml"

You should now have a configuration file in the $TARGET directory called issuance_server.toml. Feel free to check the file to see if everything looks like you’d expect.

Running the server for the first time

In section Obtaining the software we have described how you can obtain the software. In this section, we assume you have a Linux AMD64 static executable called issuance_server that you can run. We’re going to cd into the target/is-config directory, and we assume the binary exists there (it does if you followed along previously):

cd nl-wallet/target/is-config
./issuance_server

If all went well, the server is now running and ready to serve requests. To test the service, you can send session initiation and status requests to it (check out the API specifications section for how to do that).

Make sure to consider your logging settings if you need to troubleshoot.

Validating your setup

During startup, the issuance_server performs some checks on the configuration to prevent common configuration problems. Most notably the following checks are performed:

  • Verify all disclosure_settings and attestation_settings certificates are valid;

  • Verify all disclosure_settings and attestation_settings certificates are signed by any of the reader_trust_anchors and issuer_trust_anchors;

  • Verify all disclosure_settings certificates are valid reader-certificates, and contain the necessary Extended Key Usages and the reader_auth.json;

  • Verify all attestation_settings certificates are valid issuer-certificates, and contain the necessary Extended Key Usages and the issuer_auth.json;

  • Verify all disclosure_settings and attestation_settings key-pairs are valid, i.e., the public and private keys should belong together;

If this process discovers any configuration errors, the application will report an error and abort. For more insights into this process, enable logging.

Issuer API specifications

The API specifications for the issuer endpoints are available in the wallet_docs/openapi part of of the git repository.

Have a look at the OpenAPI Specifications section to learn how to open and use these.

Integrating your app with your issuance server

The wallet starts disclosure based issuance if it encounters a UL (or a QR with a UL within it) of a specific format. To create this UL, proceed as follows.

  1. If your issuance_server is reachable on the internet by the wallet at https://issuer.example.com, create a URL of the following form:

    https://issuer.example.com/disclosure/foo/request_uri?session_type=same_device
    

    In the above URL, foo has to be the identifier you used when you configured the disclosure-based issuance settings in the configuration file (so, it relates to [disclosure_settings.foo], [disclosure_settings.foo.dcql_query.credentials], and [disclosure_settings.foo.attestation_url_config])

  2. URL-encode the above URL.

  3. Create the UL as follows (newlines only for readability purposes):

    https://app.test.voorbeeldwallet.nl/deeplink/disclosure_based_issuance
      ?request_uri_method=post
      &client_id=disclosure_based_issuance.example.com
      &request_uri=https%3A%2F%2Fissuer.example.com...
    

    In which the client_id has to be the SAN DNS name from the RP certificate, and the request_uri is the URL-encoded URL from the previous step.

  4. Place this UL on your website (within in a QR code in case of cross device flows).