Create a Verifier
You want to verify certain attributes of a natural person which can be disclosed to you by the NL-Wallet.
A verifier (also known as a “relying party” or an “ontvangde voorziening”, essentially an entity that wants to verify attestations presented by the NL-Wallet) will want to have a global idea of what they needs to do when integrating their application with the NL-Wallet environment.
This document provides a global outline of components used, the necessary decisions, data, and certificate(s), and guides the setup of a so-called verifier/relying-party/ontvangende-voorziening plus integration thereof with their own frontend and backend.
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 an overview of the system architecture, specifically its main components, and where to find more information.
We’ll then cover the decisions you need to make regarding which attributes you want to verify.
We’ll list required fields you need to construct a reader_auth.json
which will
become part of your reader certificate, as a X.509v3 custom extension, and we’ll
show you how to create a reader certificate which contains the reader
authentication JSON document.
We’ll then guide you in setting up your own verification_server
, and how to
utilize the previously created reader certificate within a so-called use-case.
Finally, we’ll show you how you can know that the things you’ve configured are actually working correctly and give you guidance on how to integrate with your own application.
A note about names
Due to how we build upon existing standards, and due to terminology used in
other guidelines and architectures, a verifier
is known by various other
names. Verifier, Relying Party, Reception Service, Ontvangende Voorziening and
their acronyms RP, OV, etc, by and large reference the same thing.
In this document we use the name verifier
primarily, unless we know that a
document we reference uses one of these other names.
Architecture overview
In the above diagram, we see the main components involved in a disclosure session. The main components described in the diagram are:
DigiD: Digitale Identiteit, a digital identification system;
Pseudonym Service: A service that pseudonimizes BSN numbers;
(BRP-V) Authentic Source: A source of attributes, made accessible by a so-called Verstrekkende Voorziening (VV);
VV: Verstrekkende Voorziening, an issuer, a party that issues attributes;
OV: Ontvangende Voorziening, a verifier, a party that wants to verify attested attributes;
Relying Party Application: An app running on-premises or in-cloud of the verifier that needs to do something with the result of a verification of attributes;
Wallet App: The NL-Wallet app running on a mobile device;
Missing from the above diagram, but worth mentioning:
Wallet Web The frontend helper JavaScript/TypeScript library which helps verifiers integrate their application with the NL-Wallet platform.
Architecture documentation
This document is about setting up a verifier. To have a broader view of the NL-Wallet platform as a whole, you can have a look at the Architecture Documents.
Plaform components overview
The NL-Wallet platform consists of:
Issuers: (also known as Verstrekkende Voorzieningen), which can issue attested attributes;
Verifiers: (also known as Ontvangende Voorzieningen or Relying Parties), which can verify attested attributes they are interested in, and which this document is mainly about;
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.
Verifiers configure and maintain a verification_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 verify attested attributes.
Creating a reader authentication 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
).
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.
Decide on required metadata
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.
REQUIRED_DATA
Key |
Languages |
Description |
---|---|---|
|
|
For what purpose are you attesting? Login? Age verification? etc. |
|
- |
Do you have an intent to retain data? For how long? |
|
- |
Do you have an intent to share data? With whom? |
|
- |
Do you allow users to request deletion of their data, yes/no? |
|
|
Name of the verifier as shown in the app app. |
|
|
Legal name of the verifier. |
|
|
Short one-sentence description or mission statement of the verifier. |
|
- |
The home URL of the verifier. |
|
|
The home city of the verifier. |
|
|
Bank, Municipality, Trading, Delivery Service, etc. |
|
- |
Logo mimetype, can be image/svg+xml, image/png or image/jpeg |
|
- |
Logo image data. When SVG, an escaped XML string, else base64 |
|
- |
Two-letter country code of verifier residence. |
|
- |
Chamber of commerce number of verifier. |
|
- |
Link to verifier’s privacy policy. |
|
- |
List of attributes you want to verify. |
Note: In the Languages
column where it says nl+en
for example, please
provide both a dutch and an english answer.
Decide on attributes you want to verify
You can verify any attribute 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 use the jq
utility to query our supported
attribute names:
git clone https://github.com/MinBZK/nl-wallet
cd nl-wallet/wallet_core/lib/sd_jwt_vc_metadata/example
jq -r '(select(.vct | startswith("urn:")) | .vct) + ": " + (.claims[].path | join("."))' *.json | sort -u
The above jq
command will output a sorted unique list of namespaces and the
attribute name that namespace supports. You will need one or more of those to
configure the authorizedAttributes
object in reader_auth.json
.
For example, suppose you want to verify age_over_18
and address.country
,
then your authorizedAttributes
object would look as follows:
"authorizedAttributes": {
"urn:eudi:pid:nl:1": [["urn:eudi:pid:nl:1", "age_over_18"]],
"urn:eudi:pid-address:nl:1": [["urn:eudi:pid-address:nl:1", "address.country"]],
}
A little more background
In the verification_server
we have the concept of usecases
, which
encapsulate what you want to use a disclosure for, for example to verify a legal
age or to login to a website. Every usecase requires a reader certificate with
an X.509v3 embedded reader_auth.json
. The verification_server
can support
multiple usecases
.
In this guide we’re creating a single reader certificate (so, for a single
usecase
), but there’s nothing stopping you from creating multiple reader
certificates for different usecases
.
Creating the JSON document
When you’ve collected all the required metadata, you are ready to create the
reader_auth.json
file. For illustrative purposes, here is an example for the
municipality of Amsterdam:
{
"purposeStatement": {
"nl": "Inloggen",
"en": "Login"
},
"retentionPolicy": {
"intentToRetain": true,
"maxDurationInMinutes": 525600
},
"sharingPolicy": {
"intentToShare": false
},
"deletionPolicy": {
"deleteable": false
},
"organization": {
"displayName": {
"nl": "Gemeente Amsterdam",
"en": "City of Amsterdam"
},
"legalName": {
"nl": "Gemeente Amsterdam",
"en": "City of Amsterdam"
},
"description": {
"nl": "Alles wat we doen, doen we voor de stad en de Amsterdammers.",
"en": "Everything we do, we do for the city and the people of Amsterdam."
},
"webUrl": "https://www.amsterdam.nl",
"city": {
"nl": "Amsterdam",
"en": "Amsterdam"
},
"category": {
"nl": "Gemeente",
"en": "Municipality"
},
"logo": {
"mimeType": "image/svg+xml",
"imageData": "<svg width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" rx=\"12\" fill=\"#FF0000\"/><path d=\"M25 53.1823L29.1985 48.9481L25 44.7139L27.8015 41.8886L32 46.1228L36.1985 41.8886L39 44.7139L34.8015 48.9481L39 53.1823L36.191 56L31.9925 51.7658L27.794 56L25 53.1823ZM25 19.2861L29.1985 15.0519L25 10.8253L27.8015 8L32 12.2342L36.191 8L38.9925 10.8253L34.794 15.0595L38.9925 19.2937L36.191 22.1114L31.9925 17.8772L27.794 22.1114L25 19.2861ZM25 36.2455L29.1985 32.0114L25 27.7848L27.8015 24.9594L32 29.1936L36.1985 24.9594L39 27.7848L34.8015 32.0189L39 36.2531L36.191 39.0709L31.9925 34.8367L27.794 39.0709L25 36.2455Z\" fill=\"white\"/></svg>"
},
"countryCode": "nl",
"kvk": "34366966",
"privacyPolicyUrl": "https://www.amsterdam.nl/privacy"
},
"requestOriginBaseUrl": "https://www.amsterdam.nl",
"authorizedAttributes": {
"urn:eudi:pid:nl:1": [["urn:eudi:pid:nl:1", "bsn"]]
}
}
Take the above example, make sure you’ve read the previous sections which
explain what the different key/values mean, and construct your own
reader_auth.json
file. When we’re creating the reader certificate in the next
sections, we’re going to need it.
Creating a reader certificate
Let’s create the reader certificate. 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 creates the reader certificate and
key.
Finally, we invoke openssl
to convert our PEM certificate 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 a reader_auth.json?
You need a valid reader_auth.json
, which you can base on the example shown in
the previous section.
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 verifier on the NL-Wallet platform?
You can test your verifier locally (more or less exactly like we do with our
mock-relying-party
app) for which you don’t need anything except the code in
our git repository. But if you want to test your verifier 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 TARGET_DIR=target/ca-cert
export IDENTIFIER=foocorp
mkdir -p "${TARGET_DIR}"
# Create the reader certificate using wallet_ca.
cargo run --manifest-path "wallet_core/Cargo.toml" --bin "wallet_ca" reader \
--ca-key-file "${TARGET_DIR}/ca.${IDENTIFIER}.key.pem" \
--ca-crt-file "${TARGET_DIR}/ca.${IDENTIFIER}.crt.pem" \
--common-name "reader.${IDENTIFIER}" \
--reader-auth-file "reader_auth.json" \
--file-prefix "${TARGET_DIR}/reader.${IDENTIFIER}"
# Convert certificate PEM to DER.
openssl x509 \
-in "${TARGET_DIR}/reader.${IDENTIFIER}.crt.pem" -inform PEM \
-out "${TARGET_DIR}/reader.${IDENTIFIER}.crt.der" -outform DER
# Convert key PEM to DER.
openssl pkcs8 -topk8 -nocrypt \
-in "${TARGET_DIR}/reader.${IDENTIFIER}.key.pem" -inform PEM \
-out "${TARGET_DIR}/reader.${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 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, 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 reader 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. We will cover how to do that in this guide.
Verification server setup
After you have created a reader certificate following the previously documented
steps, you are ready to setup and configure your verification_server
.
Obtaining the software
The verification_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 verification_server \
--bin verification_server \
--locked --release
The above command creates wallet_core/target/release/verification_server
,
which is a release binary for the platform you’re running on.
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 verification_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 a verification_server
’s request_uri
field to contain an
(insecure) http://
URL in addition to a https://
URL.
Don’t allow insecure URLs on production-like environments
Don’t enable allow_insecure_url
on anything remotely production-like. To have
an idea about why, have a look at the disclosure flow diagram. Where you
see request_uri
mentioned, is where you would potentially communicate without
encryption, should you inadvertently have enabled this feature flag.
Using a database backend (optional)
The verification_server
, when compiled with the postgres
feature flag
(which is the default), can use a PostgreSQL database backend to store state.
This is done by configuring a postgres://
storage URL in the
verification_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 without a database backend
Note that you can run verification_server
with a storage URL memory://
(which is the default and 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, verification_server database name, user name and
# password for verification_server:
export DB_NAME=verification_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
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 verification_server
database schema, we will utilize the
migration tool helper:
Applying the database schema using fresh is destructive
Applying the verification_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 verification_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-09-09) 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/server_utils/migrations/Cargo.toml \
--bin verification_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
Alright, you have a reader certificate, you have might have configured a
database (optional), so you are now ready to create a verification_server.toml
configuration 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.
In the following sections we’ll create environment variables and configuration
file parts for specific settings, which we will use later to construct the
configuration file itself. In all code blocks we assume you are working within
the nl-wallet
git repository (i,e., the cd nl-wallet
is mostly informative).
The order of the following sections is equal to how these settings are written in the resulting verification_server.toml.
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/vs-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 verification_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 ./verification_server
The ephemeral ID secret
The ephemeral ID secret is used for (rotating) QR code generation, and
configured once in the verification_server.toml
:
cd nl-wallet
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/02-ephemeral-id-secret.toml"
ephemeral_id_secret = "$(dd if=/dev/urandom bs=64 count=1 | xxd -p | tr -d '\n')"
EOF
Configuring trust anchors
When you created a reader certificate, you signed that certificate using a CA, either generated by the development setup script or specifically created by you as part of the (optional) community onboarding process.
The verification 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 verification server which certificates it can trust. If a verification 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 issueance 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 VS_ISSUER_TRUST_ANCHORS=()
export VS_READER_TRUST_ANCHORS=()
for i in scripts/devenv/target/ca.issuer.crt.der target/ca-cert/ca.*.crt.der; do \
[[ -f $i ]] && VS_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 ]] && VS_READER_TRUST_ANCHORS+=($(openssl base64 -e -A -in $r)); done
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/03-trust-anchors.toml"
issuer_trust_anchors = [$(printf '"%s",' "${VS_ISSUER_TRUST_ANCHORS[@]}" | sed 's/,$//')]
reader_trust_anchors = [$(printf '"%s",' "${VS_READER_TRUST_ANCHORS[@]}" | sed 's/,$//')]
EOF
unset VS_ISSUER_TRUST_ANCHORS VS_READER_TRUST_ANCHORS
Determine public URL
The public_url
is the URL that is used by the NL-wallet app to reach the
public address and port of the verification_server
:
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/04-public-url.toml"
public_url = "https://verify.example.com/"
EOF
Use a valid domain name here
In the above, we use verify.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 verification 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 verification
server software, you did not specify the allow_insecure_url
feature flag. This
means you would need to specify an https://
url here.
Universal link base URL
The verification_server
uses the universal link base URL to construct the
correct environment-specific universal link. A universal link is used to to
associate a specific domain name and/or part of an URL with a specific app on
the mobile device. In our case, it results in the link provided by the
verification_server
being handled by the NL-Wallet app when a user clicks
on the link or scans the QR code.
A universal link base URL is usually associated with a specific backend environment like pre-production or testing. When you’re integrating with the NL-Wallet platform, you would use a universal link base URL that was provided to you as part of our community onboarding process.
cd nl-wallet
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/05-universal-link-base-url.toml"
universal_link_base_url = "https://app.example.com/ul/"
EOF
Make sure your domain is configured correctly
You as the owner of the domain (example.com
in the above example setting)
need to make sure the domain is configured correctly for universal links to
work correctly. On Apple iOS devices this is done with associated domains.
On Google Android this is configured using app links.
Access restriction settings (optional)
There are various settings related to access restriction available. You can set
an api_key
to authenticate access to the requester API, you can configure
cross-origin-resource-sharing (CORS) settings using the allow_origins
option,
and you can restrict which wallets are allowed to talk to you by configuring
wallet_client_ids
.
Not mandatory, but nonetheless wise to configure
These settings are not mandatory, but it is wise to configure these. Note that
api_key
is required when you don’t configure a requester interface. see the
configuring listener addresses and ports
section for more information about configuring listeners.
In the following sections, we document each of these access restriction settings.
Configuring allowed client IDs
You can restrict which NL-Wallet apps are accepted by the verification 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 VS_WALLET_CLIENT_IDS=("https://wallet.edi.rijksoverheid.nl")
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/06-wallet-client-ids.toml"
wallet_client_ids = [$(printf '"%s",' "${VS_WALLET_CLIENT_IDS[@]}" | sed 's/,$//')]
EOF
unset VS_WALLET_CLIENT_IDS
Configuring cross-origin resource sharing
To configure CORS, you need to add an allow_origins
array with a list of all
the origin URLs you want to allow. For example:
cd nl-wallet
export VS_ALLOW_ORIGINS=("https://example.com/")
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/07-allow-origins.toml"
allow_origins = [$(printf '"%s",' "${VS_ALLOW_ORIGINS[@]}" | sed 's/,$//')]
EOF
unset VS_ALLOW_ORIGINS
Configuring an API key
When you configure an api_key
, requests to the requester API will need an
Authorization
HTTP header containing a bearer token which looks like this:
Bearer your_secret_key
.
For example, to configure a random 32 character string as an api key:
cd nl-wallet
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/08-requester-api-key.toml"
[requester_server.authentication]
api_key = "$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)"
EOF
Configuring listener addresses and ports
The server can be configured to listen on a single IP address and port, or with a separate private (requester) and public (wallet) IP address and port. The private address can be internal and should be reachable to the application that integrates with the verifier. The public address needs to be reachable by apps like the NL-Wallet mobile app.
In our case, we’ll configure separate addresses and ports for the private and public interfaces:
cd nl-wallet
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/09-listener-addresses-and-ports.toml"
[requester_server]
ip = "10.11.12.13"
port = 8002
[wallet_server]
ip = "0.0.0.0"
port = 8001
EOF
Configure a correct and secure private IP address
In the above configuration settings, we set 10.11.12.13
as the private address
on which verification_server
will host its so-called “requester” interface.
Make sure this is a valid address for your networking environment, and that it
is hosted securely, and reachable by the application that needs to integrate
with the verifier. Do not configure 0.0.0.0
as the private address, or if you
have to, set an api_key
so the private interface API is protected from
unauthorized access.
Configure an API key when you use a single address and port
In the above settings, we configure a separate private and public interface. Your application talks to the private address and port and the “outside” world talks to the public address and port.
If you need to configure verification_server
to listen on a single
address and port, you only configure the [wallet_server]
section of the config
file and leave out the [requester_server]
section. In that case, you are
required to configure an api_key
under the [requester_server.authentication]
section (note that API key configuration is optional when you have a separate
private address and port configured). We cover configuration of an API key in
the Configuring an API key section.
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/vs-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/vs-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 use cases. To do so we need to configure a few things:
cd nl-wallet
export TARGET_DIR=target/vs-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 the use
case needs to be the HSM key label.
It is possible to use both hardware and software private keys in the same
verification 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 a use case
In the Creating a reader certificate section we’ve created a reader certificate for your use case.
We’ll assume your use case certificate is in the DER
format, stored under
target/ca-cert
and named matching reader.*.{crt,key}.der
(which it will be
if you followed this guide to create them).
You’ll have to come up with some name for your use case. In the settings below,
we set the name login-mijn-amsterdam
, which is in line with earlier examples
used during the creation of the reader certificate. Note that the name is only
used as an identifier, it can be freely chosen.
cd nl-wallet
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat <<EOF > "$TARGET_DIR/parts/12-use-case.toml"
[usecases.login-mijn-amsterdam]
session_type_return_url = "samedevice"
certificate = "$(cat target/ca-cert/reader.*.crt.der | openssl base64 -e -A)"
private_key = "$(cat target/ca-cert/reader.*.key.der | openssl base64 -e -A)"
private_key_type = "software"
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 verification_server.toml
configuration file. To generate our configuration file, issue the following
command:
cd nl-wallet
export TARGET_DIR=target/vs-config && mkdir -p "$TARGET_DIR/parts"
cat "$TARGET_DIR"/parts/*.toml > "$TARGET_DIR/verification_server.toml"
You should now have a configuration file in the $TARGET
directory called
verification_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 verification_server
that you can run. Make sure
the configuration file verification_server.toml
is in the same directory as
the binary and run it in the foreground as follows:
./verification_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 verification_server
performs some checks on the
configuration to prevent common configuration problems. Most notably the
following checks are performed:
Verify all use-case certificates are valid;
Verify all use-case certificates are signed by any of the
reader_trust_anchors
;Verify all use-case certificates are reader-certificates, and contain the necessary Extended Key Usages and the
reader_auth.json
;Verify all use-case 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.
Verifier API specifications
The API specifications for the private (also known as the requester
) and
public (also known as the wallet
) endpoints are available in the
wallet_docs/openapi
part of of the git repository.
To serve the OpenAPI specifications using a Swagger UI docker container:
cd nl-wallet
docker run --name swagger --detach --rm -p 8080:8080 \
-e URLS='[ { url: "openapi/wallet-disclosure-private.openapi.yaml", name: "Private (requester) API" }, { url: "openapi/wallet-disclosure-public.openapi.yaml", name: "Public (wallet) API" } ]' \
-e URLS_PRIMARY_NAME='Private (requester) API' \
-v "$(pwd)/wallet_docs/openapi":/usr/share/nginx/html/openapi \
swaggerapi/swagger-ui
Then visit http://localhost:8080. The above docker
invocation executes the container in the background. To see the output of the
docker container, you can run docker logs -f swagger
. To stop the container
(and remove it because we specified --rm
), you can run docker stop swagger
.
How disclosure sessions work
Now that you can interact with the NL-Wallet platform, you are ready to start working on integrating your own application.
The previously configured verification_server
, is a software component
developed by the NL-Wallet team which you as a verifier run on-premises or
within your cloud environment in order to interact with the NL-Wallet platform.
In the following subsections we’ll give you a high-level overview of what a verifier looks like, how to integrate it with your application and some directions with regards to API specifications.
What a disclosure session looks Like
In the above flow diagram you see the components involved in a disclosure session. Except for the “PID Issuer (VV)” and the “Wallet App”, these run on premises or within cloud environment(s) of the verifier (i.e., you).
Let’s walk through a typical (cross-device, note on same-device flows in following section) disclosure session (for full details, have a look at the VV/OV SAD and our component interaction flow for disclosures).
Note the possible session states:
CREATED
: session createdWAITING_FOR_RESPONSE
: waiting for user to scan or follow QR/ULDONE
which has substates:SUCCES
,FAILED
,CANCELED
, andEXPIRED
Note the “actors/components” we distinguish between:
user
: user of the app, initiating an attribute disclosure sessionwallet_app
: the NL-Wallet app, running on a users’ mobile phoneverification_server
: the verification_server component of the OVrp_frontend
: the (JavaScript/HTML/CSS) frontend of the verifier app can be-or-use previously mentionedwallet_web
JavaScript helper libraryrp_backend
: the (server) backend of the verifier application
In the diagram, the user
is the small stick-figure at the top, the actor who
initiates some task they wants to accomplish. the wallet_app
is the blue box
on the right. The verification_server
is the big block in the middle (shown as
“Verifier Service (Ontvangende Voorziening, OV)” containing the configuration,
the verifier, and the validator components). The rp_frontend
and rp_backend
are represented by the big orange/beige block on the left (shown as “Relying
Party Application”).
Overview of a flow for cross device attribute disclosure:
user
initiates action (i.e., clicks a button on web page of verifier in their desktop or mobile webbrowser);rp_frontend
receives action, asksrp_backend
to initiate session;rp_backend
in turn callsverification_server
with a session initialization request, receiving asession_url
, anengagement_url
, and adisclosed_attributes_url
as a response. The session initially has aCREATED
status;rp_backend
keepsdisclosed_attributes_url
for itself, and returnssession_url
andengagement_url
torp_frontend
;rp_frontend
encodes a QR/UL (QR Code, universal link) using theengagement_url
and displays this to theuser
;
The user
can now activate their wallet_app
QR scanner and scan the QR or
navigate to the universal link (UL). In parallel, rp_frontend
will poll the
session_url
which will change status due to action (or inaction) by the
user
. So, assuming everything goes fine:
rp_frontend
pollssession_url
for status. It will re-poll for a configured time-limit when receiving aCREATED
orWAITING_FOR_RESPONSE
status. The poll will terminate onDONE
;After
user
completes the scanning of the QR or followed the universal link,wallet_app
parses/extracts the QR/UL and starts a device engagement session withverification_server
, which in turn returns the verifier details and the requested attributes to thewallet_app
;The
wallet_app
shows the verifier details and the requested attributes to theuser
and gives theuser
the option to consent or abort;
The user
can abort, which will terminate the session with a CANCELED
status.
The user
can also wait too long, which would result in an EXPIRED
status.
The FAILED
status can occur when other, infrastructural and/or network-related
problems are encountered. Assuming the user
consented, let’s continue:
wallet_app
sends a device response containing the disclosed attributes and proofs_of_possession to theverification_server
;verification_server
validates if attributes are authentic and valid and if they belong together and returns an indication of success back to thewallet_app
, which in turn confirms the success by displaying a dialog to theuser
.verification_server
additionally updates the status of the session toDONE
with theSUCCESS
substate (assuming validation went fine);The poll running on the
rp_frontend
will terminate due to theDONE
session state;The
rp_frontend
returns the result of the session to therp_backend
;The
rp_backend
checks the status of the session. OnDONE
with substateSUCCESS
, it will call the associateddisclosed_attributes_url
which it kept around (saved) in step 4 to retrieve the disclosed attributes. When substate is notSUCCESS
, it will not retrieve the disclosed attributes but invoke an error_handler of sorts (for example) which displays the error condition;rp_backend
handles disclosed attributes, returns status torp_frontend
(for example: user is authenticated, here have a token);
Cross device vs. same device flows
Same-device flows differ from cross-device flows in how the QR/UL is encoded.
The rp_frontend
detects the user-agent and from that determines if a
Cross-device or Same-device flow is appropiate. When it encodes for a
Same-device flow, the resulting Universal link can be directly opened by the
wallet_app
on the same device, which then starts device engagement towards the
verification_server
(see step 7 above).
Requirements applicable to your application
Below you’ll find a list of things to know about the NL-Wallet platform and more specifically, what you need to keep in mind when you integrate the usage of the app for identification or verification of attributes with your application:
The NL-Wallet app presents attestations using the OpenID4VP protocol standard using either the SD-JWT or the ISO/IEC 18013-5:2021 MDOC credential format;
Any disclosure session initiation request must include the reason why the verifier is requesting the attributes;
A verifier MUST NOT track, in the broadest sense of the word;
A verifier needs to adhere to the EU-GDPR (Nederlands: EU-AVG) GDPR;
It is required to follow accessibility guidelines set forth in the WCAG;
It is expected that you use the
wallet_web
frontend helper library;The standard buttons for login and sharing should be used, but one can use custom button text (within reason);
Button styling and call-to-action can be customized by verifier;
The text “NL-Wallet” should always be visible in the call-to-action;
Logo of “NL-Wallet” should be visible next to the call-to-action.
Integrating your app with your verification server
If you look at the previous disclosure flow diagram, on the left side, you see the “Relying Party Application”, which is an application you probably already have that you want to integrate with functionality the app provides (i.e., the verification of identity and/or certain specific attributes, in order to allow or disallow usage of (a part of) said application).
To integrate with the verifier, you modify your frontend and backend app, using
the wallet_web
frontend library, integrating with your previously configured
verification_server
.
In the disclosure flow diagram, on the right, where the “Relying Party Application” is shown, you see a four integration/call points: “Configure Verifier”, “Initiate Disclosure Session”, “Start Result Poll Loop” and “Retrieve OV Result”:
Configuration of the verifier, executed manually by you, a one-time initial setup which is documented in this guide;
Initiation of a disclosure session, executed by your backend application;
The status check loop, executed by your frontend application, where we check for a status result, which indicates success or failure of the session.
Result retrieval, executed by your backend, which is a final conditional step dependent on a succesful completion status, which contains the disclosed_attributes.
The above is described in more detail in the previous section detailing an example disclosure flow.
It’s worth noting that the NL-Wallet team has developed a JavaScript library
(called wallet_web
) that handles the status check loop and status return for
you.
References
Below you’ll find a collection of links which we reference to through the entire text. Note that they don’t display when rendered within a website, you need to read the text in a regular text editor or pager to see them.