Verify Credentials from AltID
AltID is the Danish national identity wallet, run by Digitaliseringsstyrelsen (the Danish Agency for Digital Government). It holds an age credential and an identity credential, and presents them as ISO mdoc documents over OID4VP.
The credential verification service speaks both protocols already, so no new software is involved. What AltID needs is configuration you cannot generate yourself — a certificate issued to your organisation and a registration that goes with it — plus a verification query written for the mdoc format.
This article covers the verifier's side of that. Everything on the AltID side is Digitaliseringsstyrelsen's to document, and their material is the authority: Teknisk integration til AltID and Vejledninger til AltID.
Requirements
- A running credential verification service. See Install the verification service.
- An OCES organisation certificate issued to your organisation through MitID Erhverv.
- A registration in the AltID-modtagerregister.
What is different about AltID
Four things, and nothing else in the integration changes:
| What | Setting | Why AltID needs it |
|---|---|---|
| Your identity to it | altIdSigning |
AltID accepts only requests signed by an OCES certificate, which cannot be your signing certificate. |
| Your registration | verifierInfo |
Proves to the app that you are in the AltID-modtagerregister, so the holder is told who is asking. |
| The issuer you trust | issuerTrustAnchors |
Mandatory for mdoc. Without it no AltID credential verifies at all. |
| The query | format: mso_mdoc and uri_prefix |
Selects the mdoc format, and makes the deep link open the AltID app. |
The calls your application makes — start a session, show the deep link, receive the result — are exactly the ones in Request a presentation.
1. Get the certificate
AltID identifies you by an OCES organisation certificate, issued to your organisation under
Den Danske Stat OCES rod-CA. You obtain it through MitID Erhverv; Digitaliseringsstyrelsen publishes a guide for
exactly that, linked from
Vejledninger til AltID.
It arrives as a password-protected PKCS#12 file. The service wants PEM, so convert it:
mkdir -p verifier/conf/altid
# The private key, as an unencrypted PKCS#8 PEM
openssl pkcs12 -in altid.p12 -nocerts -nodes |
openssl pkcs8 -topk8 -nocrypt -out verifier/conf/altid/altid-verifier-signing.key
# Your own certificate
openssl pkcs12 -in altid.p12 -clcerts -nokeys |
openssl x509 -out verifier/conf/altid/altid-verifier-signing.pem
# The two CA certificates above it, one file each
openssl pkcs12 -in altid.p12 -cacerts -nokeys |
awk '/BEGIN CERT/{n++} n{print > ("verifier/conf/altid/oces-ca-" n ".pem")}'
The two CA files come out in no particular order, so check which is which with
openssl x509 -in <file> -noout -subject and name them accordingly. The chain runs from your own certificate up
through Den Danske Stat OCES udstedende-CA 1 to Den Danske Stat OCES rod-CA; the rest of this article calls
those two files oces-issuing-ca.pem and oces-root-ca.pem.
This certificate is RSA, and that is why it is a second one
OCES certificates are RSA. The HAIP profile the service follows for everything else requires EC, so one
certificate cannot serve both. That is the whole reason altIdSigning exists alongside
signing — see
Which certificate signs what.
2. Register in the AltID-modtagerregister
Your organisation has to be listed in the AltID-modtagerregister at modtager.tegnebog.dk. The register is what lets a citizen see in the app who is asking for their data, and registration is a legal requirement under the Danish act on the national digital identity wallet.
You upload the certificate from step 1 in CER format — Digitaliseringsstyrelsen publishes a guide for the export —
and receive a registration token back: a JWT with typ: dktb-rpr+jwt, issued by
https://modtager.tegnebog.dk.
The registration is bound to that one certificate
The token's sub is x509_hash: followed by the SHA-256 hash of the certificate you uploaded — the same
value the service uses as its OID4VP client_id. Replacing the certificate invalidates the registration, so
a rotation means registering the new certificate before you deploy it.
The token also carries allowed_names: the organisation names your registration covers. The name AltID shows
the holder comes from there, not from the clientMetadata.clientName other wallets read, so the two need not
match.
3. Configure the verification service
Three settings, on top of the configuration from Install the verification service:
{
"port": 8081,
"baseUrl": "https://verifier.example.org",
"database": {
"..."
},
"clientMetadata": {
"clientName": "Example Organisation ApS" // (1)!
},
"integrations": {
"..."
},
"signing": { // (2)!
"keyFile": "conf/signing-key/verifier.key",
"certificateFiles": [
"conf/signing-key/verifier.pem"
]
},
"altIdSigning": { // (3)!
"keyFile": "conf/altid/altid-verifier-signing.key",
"certificateFiles": [
"conf/altid/altid-verifier-signing.pem",
"conf/altid/oces-issuing-ca.pem",
"conf/altid/oces-root-ca.pem"
]
},
"verifierInfo": "conf/altid/verifier-info.json", // (4)!
"issuerTrustAnchors": "conf/altid/issuer-trust-anchors" // (5)!
}
- What wallets that read
client_metadatashow the holder. AltID shows a name from your registration instead, so this value does not have to match the names in your registration token. - Unchanged. This is still the certificate used for every credential format other than mdoc.
- Your OCES certificate and key, chain ordered leaf first. Omitting it makes the service sign AltID requests
with
signinginstead, which AltID refuses. - The registration token from step 2, wrapped in a JSON file — see below.
- Directory of
*.pemcertificates to accept AltID's credential issuer under. Required for mdoc.
All paths are resolved against the container's working directory /app, so mount verifier/conf there as usual:
-v "$(pwd)/verifier/conf:/app/conf:ro".
verifierInfo
verifierInfo points at a JSON file, not at the token itself. The file holds a list, and each entry names the
format of what it carries:
[
{
"format": "dktb-rpr+jwt",
"data": "eyJ4NWMiOlsiTUlJQjZqQ0NBWStnQXdJQkFnSVFFTW5RT2FnOGh1Sld0YzVuNjZMamFEQUtCZ2dxaGtqT1BRUURBakJXTVFzd0..."
}
]
The list is sent verbatim as the verifier_info field of every authorization request. AltID validates the token
and shows the holder the registered organisation name.
issuerTrustAnchors
This is the ordinary issuer trust anchors setting: a directory
whose *.pem files are the roots the verifier will trace a credential's certificate chain back to. Put the
certificate Digitaliseringsstyrelsen publishes for the AltID credential issuer in it.
The certificate to trust differs between the test and the production environment. They are available in Vejledninger til AltID
The directory is read once at startup, so restart the service after changing it.
Which certificate signs what
The service picks the signing certificate per request, from the credential format the query asks for:
- A query asking for
mso_mdocis answered with an encrypted response, and its authorization request is signed withaltIdSigning. - Every other query is signed with
signing.
The client_id is derived from whichever certificate signed the request, so the two kinds of session identify you
by different values — both in the signed request and in the deep link the wallet is handed. This is automatic and
there is nothing to configure: one verifier serves AltID and your other wallets side by side.
4. Write the verification query
A verification query for AltID differs from an SD-JWT one in four places:
curl -X POST 'https://verifier.example.org/verification-query/create' \
-H 'Authorization: Bearer <your API key>' \
-H 'Content-Type: application/json' \
-d '{
"dcql_query": {
"credentials": [
{
"id": "av",
"format": "mso_mdoc",
"meta": { "doctype_value": "eu.europa.ec.av.1" },
"claims": [
{
"id": "ageOver16",
"path": ["eu.europa.ec.av.1", "age_over_16"]
}
]
}
]
},
"uri_prefix": "https://app.tegnebog.dk/oid4vp"
}'
"format": "mso_mdoc"selects the ISO mdoc format. It is also what makes the service sign the request withaltIdSigningand require an encrypted response.meta.doctype_valueis required for mdoc, and must name the document type exactly. A presentation whose document carries a differentdocTypeis rejected.- Each claim
pathhas exactly two elements,[namespace, elementIdentifier]— mdoc claims are not nested the way SD-JWT claims are. A path of any other length is rejected when the presentation arrives. uri_prefixishttps://app.tegnebog.dk/oid4vp, so the deep link opens the AltID app instead of the defaulthaip-vp://.
The two credentials AltID presents are:
| Credential | doctype_value |
Namespace |
|---|---|---|
| Age credential | eu.europa.ec.av.1 |
eu.europa.ec.av.1 |
| Identity credential | eu.europa.ec.eudi.pid.1 |
eu.europa.ec.eudi.pid.1, eu.europa.ec.eudi.pid.dk.1 |
The identity credential carries the EUDI PID elements — family_name, given_name, birth_date,
resident_address and the rest — in the eu.europa.ec.eudi.pid.1 namespace, with Danish additions in
eu.europa.ec.eudi.pid.dk.1. The age credential carries age_over_NN booleans. Which elements exist in each is
listed in the AltID technical documentation, and asking for one the credential does not carry fails the
presentation rather than returning nothing.
One credential query per verification query
An mdoc verification query must contain exactly one entry in credentials, and the wallet returns exactly one
document for it. Asking for the age credential and the identity credential together means two sessions.
5. Show the deep link
Unchanged from Request a presentation: start a session, and put the request_uri
the service returns in front of the holder. Because uri_prefix is an https address, the value is a link the
phone hands to the AltID app rather than a custom scheme:
{
"session_id": "14eb99d399dda3264a0ad85b93b26eab5396ce17e50056968c2749049ebf6ec1",
"request_uri": "https://app.tegnebog.dk/oid4vp?request_uri=https%3A%2F%2Fverifier.example.org%2Fpresentation%2Foid4vp%2Fauthorization-request%2F14eb99d3...&client_id=x509_hash%3AaGCb_7KoazC3S8fUwgobO0BrH-yuG-Ejq9I5WiVmL-0"
}
Pass it through unchanged. On a desktop browser, render it as a QR code; on the holder's own phone, present it as a link. Digitaliseringsstyrelsen publishes design guidelines for how the QR code and the surrounding text should look, so that citizens recognize an AltID request across the services they meet.
6. Read the result
The result arrives at your callback in the usual shape. Claim keys follow the same
rule as everywhere else — the id you gave the claim, or the elements of path joined with dots:
{
"session_id": "14eb99d399dda3264a0ad85b93b26eab5396ce17e50056968c2749049ebf6ec1",
"verification_result": true,
"claims": {
"ageOver16": true
}
}
Without an id on the claim query, that key would have been eu.europa.ec.av.1.age_over_16. Setting id is
worth doing here: the mdoc path is long, and it is the namespace rather than anything about your integration.
For mdoc, verification_result: true means the issuer's signature over the document validated, its certificate
chain reached one of your trust anchors, the disclosed values match the digests in the signed document, and the
holder's device signed this specific request — the last of which is why the response is encrypted.
Testing before you go live
Digitaliseringsstyrelsen offers a test version of the AltID app and a hosted test tool, both requested by mail
from AltID@digst.dk; the conditions are on
Vejledninger til AltID. Point
issuerTrustAnchors at the test environment's certificate while you use them, and remember to swap it back.
The rest of the checklist is the ordinary one: baseUrl must be an address the holder's phone can reach, since
that is where the app fetches the signed request from. See
Going to production.
Troubleshooting
trustAnchors must be non-null and non-empty.
issuerTrustAnchors is unset, points at a directory that does not exist, or holds no *.pem files. Unlike
SD-JWT, mdoc cannot verify without it.
The AltID app rejects the request, or never shows a consent screen.
In order: check the startup log for Failed to load verifier info, and check that the certificate in
altIdSigning is the one your registration token was issued for and has not expired. Both are checked by the app,
not by the service, so the session simply never progresses past VERIFICATION_STARTED.
Scanning the QR code does nothing.
Either uri_prefix is not https://app.tegnebog.dk/oid4vp, so the link never reaches the app, or baseUrl is an
address the phone cannot resolve. See
Request a presentation.
doctype_value is required and must be a String.
The credential query has no meta.doctype_value. It is optional for SD-JWT and required for mdoc.
DeviceResponse document docType '…' does not match requested docType '…'.
The holder presented a different credential than the one you asked for. Check doctype_value against the table
above.
mdoc DCQL claims path must have exactly 2 elements [namespace, elementIdentifier].
A claim path was written in the SD-JWT style. Every mdoc path is exactly [namespace, element].
Requested claim not found in mdoc.
The namespace or the element identifier does not exist in the presented credential. The message names both.
The general failures are in Troubleshooting.
What's Next
- Request a presentation: The integration itself, unchanged for AltID.
- Receive verified data: Configure the callback.
- Configuration reference: Every verifier setting.
- Trust model: What the certificate chains establish, on both sides.