Skip to content

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:

server.json
{
  "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)!
}
  1. What wallets that read client_metadata show the holder. AltID shows a name from your registration instead, so this value does not have to match the names in your registration token.
  2. Unchanged. This is still the certificate used for every credential format other than mdoc.
  3. Your OCES certificate and key, chain ordered leaf first. Omitting it makes the service sign AltID requests with signing instead, which AltID refuses.
  4. The registration token from step 2, wrapped in a JSON file — see below.
  5. Directory of *.pem certificates 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:

conf/altid/verifier-info.json
[
  {
    "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_mdoc is answered with an encrypted response, and its authorization request is signed with altIdSigning.
  • 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 with altIdSigning and require an encrypted response.
  • meta.doctype_value is required for mdoc, and must name the document type exactly. A presentation whose document carries a different docType is rejected.
  • Each claim path has 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_prefix is https://app.tegnebog.dk/oid4vp, so the deep link opens the AltID app instead of the default haip-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.

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