Hermes Platform
beID

beID Helper

Developer · Internal tools

Developer instructions

Integration guide for Service Providers (SPs). Machine-readable version: integration.md. Want to try your integration? Use the Test-SP.

1) Overview (SP ↔ beID Hermes Platform)

As an SP you talk only to the beID Hermes Platform. The user does not have to manage the local helper themselves.

  1. Build a JSON payload with one operation.
  2. Base64-encode the JSON → payload_b64.
  3. Sign the tagged string "beid-start-v1:" + payload_b64 with your private key.
  4. POST-redirect to the start endpoint (see below).
  5. The platform reads the card and 302-redirects the browser to return_url?code=<opaque>&state=<state>.
  6. Your backend exchanges the code via a server-to-server POST to token.php and receives the result_token.

On error or cancellation the platform redirects to return_url?error=<code>&state=<state> — see Error callbacks.

2) Start request (SP → Platform)

Use this exact endpoint address:

POST https://beid.hermesplatform.be/hosted/start.php

POST fields:

fielddescription
payload_b64base64(JSON) of the start payload (see below)
sp_sig_algES256 or RS256
sp_signaturebase64( signature over "beid-start-v1:" + payload_b64 )

Payload JSON (base64-encoded in payload_b64):

{
  "sp_id": "blancefloer_intranet",
  "op": "read:identity",
  "return_url": "https://sp.example/return",
  "state": "a3f9c2e1d4b87650",
  "ts": 1700000000,
  "nonce": "d8e2f1a4c3b96750",
  "tbs_b64": "..."
}

Field reference:

fieldrequireddescription
sp_idyesThe config key assigned at registration (e.g. blancefloer_intranet). This is the object name in the platform configuration — not a password or certificate.
opyesExactly one operation per request (see the list below). A value like "read:identity read:certs" is an error.
return_urlyesMust match a registered base URL on your SP's allowlist. An allowlist is mandatory; without one the request is rejected (see return_url allowlist).
statenoOpaque string, returned unchanged in the redirect URL and the result JWT. Use it to tie the result to the user session.
tsyesUnix timestamp in seconds. Requests older than 5 minutes are rejected.
nonceyesRandom unique value per request (anti-replay).
tbs_b64yes for auth/signbase64 of the bytes to sign. With prehashed=false (default) this is the raw message; with prehashed=true this is the already-computed digest.
digest_algno (auth/sign)"SHA256" or "SHA384" (default "SHA384"). Selects the hash algorithm.
prehashedno (auth/sign)false (default) — the helper hashes tbs_b64 with digest_alg; truetbs_b64 is already the digest (32 bytes for SHA256, 48 for SHA384).

Valid values for op:

valuedescription
read:identityPersonal data: name, national number, date of birth, gender, … (no address)
read:addressAddress: street, postal code, municipality. A separate call from read:identity.
read:identity+addressRead identity and address in one card session. Returns a nested result object.
read:photoID photo (JPEG).
read:certsAll certificates on the card.
authSign bytes with the authentication key.
signSign bytes with the non-repudiation key.

Signature rules:

3) Response: redirect and token exchange

Step 1 — Platform redirects to your return_url

On success the platform performs a 302 redirect to:

GET https://sp.example/return?code=<opaque>&state=a3f9c2e1d4b87650

This is a top-level GET navigation, so SameSite=Lax session cookies are sent correctly — no special cookie configuration needed.

Step 2 — Exchange the code for the result token (server-to-server)

Your backend POSTs to token.php:

POST https://beid.hermesplatform.be/hosted/token.php
fielddescription
codeOpaque code from the redirect URL.
sp_idYour SP identifier.
sp_sig_algSame algorithm as the start request (ES256 or RS256).
sp_signaturebase64( signature over "beid-code-v1:" + code ) — same key and algorithm as the start request, but with the beid-code-v1: prefix (note: a different prefix from the start request's beid-start-v1:).

The code is single-use, has a TTL of ~120 seconds and is bound to your sp_id.

Successful response (200 OK, Content-Type: application/json):

{ "result_token": "<JWT>" }

Error response (4xx):

{ "error": "<description>" }

Validate the result_token

Verify the JWT signature with the platform's public key:

https://beid.hermesplatform.be/keys/challenge_pub.pem — algorithm: ES256.

Result token claims:

{
  "iss": "beid.hermesplatform.be",
  "aud": "sp",
  "sp_id": "blancefloer_intranet",
  "op": "read:identity",
  "status": "ok",
  "state": "a3f9c2e1d4b87650",
  "payload_b64": "...",
  "result_hash": "...",
  "iat": 1700000000,
  "exp": 1700000300,
  "jti": "..."
}

status is always "ok" in a successfully exchanged token. Decode payload_b64 for the result data.

state: present both as a query parameter in the redirect URL and as a JWT claim. Read it from the JWT claims — that value is signed by the platform. Compare it with the value you stored in the session before the redirect to detect CSRF.
The payload_b64 name in the result JWT: This claim contains the result data (identity, address, signature …) as base64(JSON). This is not the request payload you sent in the start request — the name recurs, but the content is entirely different.

4) Error callbacks

On cancellation, PIN error, no card or server error the platform redirects to:

GET https://sp.example/return?error=<code>&state=a3f9c2e1d4b87650
error codecause
access_deniedUser cancelled or refused
pin_failedWrong PIN (best-effort detection)
no_cardNo card / reader / token present
helper_unavailableHelper not installed or unreachable
helper_outdatedHelper too old for the requested operation
server_errorInternal error on the platform side
Security note: Error callbacks are advisory and unverified — anyone can craft a link to your return_url?error=.... Treat only a successfully exchanged, signature-verified result_token as authoritative. The worst an attacker can do is redirect a user to your own callback with an error code (the return_url is on the allowlist, so no open redirect is possible).

5) return_url allowlist

Every SP must have at least one allowed return URL. A start request from an SP without an allowlist is rejected — there is no implicit "allow all" fallback.

Registration records the base of a URL: scheme://host[:port]/path. A return_url in the start request is accepted if its base (with query string and fragment stripped) exactly matches a registered base. Query parameters are allowed on a registered base — you may include query parameters in return_url, as long as scheme, host and path match exactly.

6) Field names per operation

Fields marked omitempty are absent from the JSON when the card returns an empty value.

6.1 read:identity

fieldtypeformat / note
national_numberstringNational register number
surnamestring
given_namesstringAll given names, space-separated
third_given_initialstringInitial of the third given name
birth_datestringYYYY-MM-DD (ISO 8601) when the card contains a full date. Rare older cards may contain a partial value (e.g. year only) — parse defensively.
birth_locationstring
genderstringValue as on the card (M, F, …)
nationalitystring
card_numberstring
validity_begin_datestringDD.MM.YYYY
validity_end_datestringDD.MM.YYYY
issuing_municipalitystring

Address data (street_and_number, zip, municipality) is not part of read:identity — use a separate read:address call, or read:identity+address to fetch both in one card session.

6.2 read:address

fieldtypeformat / note
street_and_numberstringStreet and house number combined
zipstringPostal code — not postal_code
municipalitystring

6.3 read:identity+address

Reads identity and address in one card session (one PIN entry). The payload_b64 in the result token decodes to a nested object:

{
  "identity": {
    "national_number": "...",
    "surname": "...",
    "given_names": "...",
    "birth_date": "YYYY-MM-DD",
    "..."
  },
  "address": {
    "street_and_number": "...",
    "zip": "...",
    "municipality": "..."
  }
}

The fields within identity and address are identical to those of the separate read:identity and read:address operations.

Note: This operation requires a helper that supports the /enc/read/person endpoint. If the installed helper is too old, the platform returns an error redirect with error=helper_outdated.

6.4 read:photo

fieldtypenote
mimestringAlways image/jpeg
photo_b64stringbase64-encoded JPEG

6.5 read:certs

Array certs, per item:

fieldtypenote
labelstringe.g. Authentication, Signature
id_hexstringCKA_ID as hex
cert_der_b64stringbase64-encoded DER X.509 certificate
cert_fp_sha256stringSHA-256 fingerprint (hex)

6.6 auth / sign

Requires tbs_b64 in the start payload. The signature is ECDSA P-384 (newer cards) or RSASSA-PKCS1-v1_5 / RSA 2048 (older cards); the signature_format field tells you which. See the card-generations note below.

Digest options (optional fields in the start payload):

fielddefaultdescription
digest_alg"SHA384"Hash algorithm: "SHA256" or "SHA384".
prehashedfalseIf false: the helper hashes tbs_b64 with digest_alg. If true: tbs_b64 is already the digest (32 bytes for SHA256, 48 for SHA384) and is signed as-is.

With digest_alg: "SHA256" the helper produces an ecdsa-with-SHA256 signature (newer ECDSA cards) or a SHA-256 RSASSA-PKCS1-v1_5 signature (older RSA cards), suitable for PAdES- and eID-Easy-style flows on the eID key.

Card generations — RSA (older) vs ECDSA (newer). Older Belgian eID cards are RSA 2048; newer cards are ECDSA P-384. (RSA cards were issued at least through 2018, so this is a large share of the active population.) The helper selects the algorithm automatically from the card key type — no SP action required — and reports it: newer cards return signature_format: "der" with mechanism ECDSA-SHA384/ECDSA-SHA256 (ASN.1 DER), older cards return signature_format: "pkcs1" with mechanism RSA-SHA256/RSA-SHA384 (RSASSA-PKCS1-v1_5, 256 bytes). Both digest_alg values and both prehashed modes work on both generations. Verify against the public key in the returned certificate using the algorithm in mechanism.

Result fields:

fieldtypenote
signature_formatstring"der" (ECDSA cards) or "pkcs1" (RSA cards)
signature_b64stringbase64-encoded signature: DER ECDSA (ASN.1 SEQUENCE of r and s) on ECDSA cards, or raw RSASSA-PKCS1-v1_5 (256 bytes) on RSA cards
cert_der_b64stringbase64-encoded DER X.509 certificate of the signing key
cert_fp_sha256stringSHA-256 fingerprint (hex)
keystring"auth" or "sign"
mechanismstring"ECDSA-SHA256"/"ECDSA-SHA384" (ECDSA cards) or "RSA-SHA256"/"RSA-SHA384" (RSA cards), depending on card type and digest_alg

6.7 Verifying the card signature (both card generations)

An SP that verifies the card signature itself must not assume ECDSA — older RSA cards return a PKCS#1 signature. You do not need to branch on the format: openssl_verify accepts the DER ECDSA signature ("der") and the raw RSASSA-PKCS1-v1_5 signature ("pkcs1") as-is, and the certificate's public key selects RSA vs ECDSA. Just pick the hash from mechanism:

// $r = decrypted /enc/sign result; $message = the exact bytes that were signed.
function verify_card_signature(array $r, string $message): bool {
    $pem = "-----BEGIN CERTIFICATE-----\n"
         . chunk_split($r['cert_der_b64'], 64)
         . "-----END CERTIFICATE-----\n";
    $pub = openssl_pkey_get_public($pem);
    if ($pub === false) return false;
    $sig = base64_decode($r['signature_b64']);
    $alg = substr($r['mechanism'], -6) === 'SHA384'
        ? OPENSSL_ALGO_SHA384 : OPENSSL_ALGO_SHA256;
    return openssl_verify($message, $sig, $pub, $alg) === 1; // RSA and ECDSA both
}

If you previously hard-coded ECDSA handling (DER→raw r||s conversion, a fixed signature length, or curve P-384), remove it. For prehashed: true flows you hold only the digest, not $message; verify the raw signature against that digest with the lower-level openssl primitives instead.

7) Implementation tips

7.1 Session continuity

The platform delivers the result via a top-level GET redirect to your return_url. SameSite=Lax cookies — including the default PHP session cookie — are sent correctly on top-level GET navigations. No special cookie configuration needed.

Store a state value in the session before the redirect, and on the callback verify it matches the value in the redirect URL and in the signed JWT claim. This prevents CSRF.

7.2 Error handling

On error or cancellation the platform redirects to return_url?error=<code>&state=<state>. Your callback endpoint must handle both the success case (code parameter present) and the error case (error parameter present).

Error redirects are advisory and unverified. Treat only a successfully exchanged, signature-verified result_token as authoritative.

8) Examples (SP side)

8.1 PHP – start (signed POST-redirect)

<?php
$payload = [
  'sp_id'      => 'your_sp_id',   // config key assigned at registration
  'op'         => 'read:identity', // exactly one operation
  'return_url' => 'https://sp.example/return',
  'state'      => bin2hex(random_bytes(16)),
  'ts'         => time(),
  'nonce'      => bin2hex(random_bytes(16)),
];
$payloadB64 = base64_encode(json_encode($payload, JSON_UNESCAPED_SLASHES));
$privKey = openssl_pkey_get_private(file_get_contents('/path/to/sp_priv.pem'));
openssl_sign('beid-start-v1:' . $payloadB64, $sigDer, $privKey, OPENSSL_ALGO_SHA256);
$sigB64 = base64_encode($sigDer);
?>
<form method="POST" action="https://beid.hermesplatform.be/hosted/start.php">
  <input name="payload_b64"  value="<?php echo htmlspecialchars($payloadB64); ?>" />
  <input name="sp_sig_alg"   value="ES256" />
  <input name="sp_signature" value="<?php echo htmlspecialchars($sigB64); ?>" />
</form>
<script>document.forms[0].submit()</script>

8.2 PHP – callback (exchange code, verify result_token)

<?php
require 'vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

// Callback receives GET ?code=...&state=... (success) or ?error=...&state=... (error)
$error = $_GET['error'] ?? '';
if ($error !== '') {
    // Advisory — show a user-friendly message; do NOT treat as authoritative
    die("eID error: " . htmlspecialchars($error));
}

$code  = $_GET['code']  ?? '';
$state = $_GET['state'] ?? '';

// Verify state against the value stored in the session before the redirect
if (!hash_equals($_SESSION['beid_state'] ?? '', $state)) {
    die("State mismatch");
}

// Sign the tagged code string for the token exchange
$privKey = openssl_pkey_get_private(file_get_contents('/path/to/sp_priv.pem'));
openssl_sign('beid-code-v1:' . $code, $sigDer, $privKey, OPENSSL_ALGO_SHA256);
$sigB64 = base64_encode($sigDer);

// Exchange code for result_token (server-to-server)
$ch = curl_init('https://beid.hermesplatform.be/hosted/token.php');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query([
        'code'         => $code,
        'sp_id'        => 'your_sp_id',
        'sp_sig_alg'   => 'ES256',
        'sp_signature' => $sigB64,
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$resp  = json_decode(curl_exec($ch), true);
$token = $resp['result_token'] ?? '';

// Verify signature and decode
$pubPem = file_get_contents('https://beid.hermesplatform.be/keys/challenge_pub.pem');
$claims = JWT::decode($token, new Key($pubPem, 'ES256'));
$data   = json_decode(base64_decode($claims->payload_b64), true);
// $data['national_number'], $data['surname'], $data['birth_date'], ...
?>

8.3 ASP.NET (C#) – start

var payload = new {
  sp_id      = "your_sp_id",      // config key assigned at registration
  op         = "read:identity",   // exactly one operation
  return_url = "https://sp.example/return",
  state      = Guid.NewGuid().ToString("N"),
  ts         = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
  nonce      = Guid.NewGuid().ToString("N")
};
var payloadB64 = Convert.ToBase64String(
  System.Text.Encoding.UTF8.GetBytes(
    System.Text.Json.JsonSerializer.Serialize(payload)));
var ecdsa = System.Security.Cryptography.ECDsa.Create();
ecdsa.ImportFromPem(File.ReadAllText("sp_priv.pem"));
// Signature MUST be ASN.1/DER encoded (Rfc3279DerSequence). The .NET default
// is IEEE P1363 (r||s), which the platform rejects as "invalid signature".
var sigB64 = Convert.ToBase64String(
  ecdsa.SignData(System.Text.Encoding.ASCII.GetBytes("beid-start-v1:" + payloadB64),
    System.Security.Cryptography.HashAlgorithmName.SHA256,
    System.Security.Cryptography.DSASignatureFormat.Rfc3279DerSequence));

8.4 ASP.NET (C#) – callback (exchange code, verify result_token)

// Callback receives GET ?code=...&state=... or ?error=...&state=...
var error = Request.Query["error"].ToString();
if (!string.IsNullOrEmpty(error))
    return Problem("eID error: " + error); // advisory

var code  = Request.Query["code"].ToString();
var state = Request.Query["state"].ToString();
// Verify state against the session value

// Sign the tagged code string
var ecdsaSp = System.Security.Cryptography.ECDsa.Create();
ecdsaSp.ImportFromPem(File.ReadAllText("sp_priv.pem"));
// DER signature format required — see note in 8.3.
var sigB64 = Convert.ToBase64String(
    ecdsaSp.SignData(System.Text.Encoding.ASCII.GetBytes("beid-code-v1:" + code),
                     System.Security.Cryptography.HashAlgorithmName.SHA256,
                     System.Security.Cryptography.DSASignatureFormat.Rfc3279DerSequence));

// Exchange code for result_token (server-to-server)
using var http = new HttpClient();
var resp = await http.PostAsync(
    "https://beid.hermesplatform.be/hosted/token.php",
    new FormUrlEncodedContent(new Dictionary<string, string> {
        ["code"]         = code,
        ["sp_id"]        = "your_sp_id",
        ["sp_sig_alg"]   = "ES256",
        ["sp_signature"] = sigB64,
    }));
var body  = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>();
var token = body.GetProperty("result_token").GetString();

// Verify result_token
var ecdsa  = System.Security.Cryptography.ECDsa.Create(
  System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
ecdsa.ImportFromPem(File.ReadAllText("platform_pub.pem"));
var parms = new Microsoft.IdentityModel.Tokens.TokenValidationParameters {
  ValidateIssuer = true, ValidIssuer = "beid.hermesplatform.be",
  ValidateAudience = true, ValidAudience = "sp",
  IssuerSigningKey = new Microsoft.IdentityModel.Tokens.ECDsaSecurityKey(ecdsa),
  ValidateLifetime = true
};
var principal  = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler()
  .ValidateToken(token, parms, out _);
var data = System.Text.Json.JsonDocument.Parse(
  Convert.FromBase64String(
    principal.Claims.First(c => c.Type == "payload_b64").Value));

8.5 Java – start

String payloadB64 = Base64.getEncoder().encodeToString(
  new ObjectMapper().writeValueAsString(Map.of(
    "sp_id",      "your_sp_id",
    "op",         "read:identity",
    "return_url", "https://sp.example/return",
    "state",      UUID.randomUUID().toString().replace("-",""),
    "ts",         Instant.now().getEpochSecond(),
    "nonce",      UUID.randomUUID().toString().replace("-","")
  )).getBytes(StandardCharsets.UTF_8));
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initSign(loadPrivateKey("/path/sp_priv.pem"));
sig.update(("beid-start-v1:" + payloadB64).getBytes(StandardCharsets.US_ASCII));
String sigB64 = Base64.getEncoder().encodeToString(sig.sign());

8.6 Java – callback (exchange code, verify result_token, Nimbus JOSE + JWT)

// Callback receives GET ?code=...&state=... or ?error=...&state=...
String error = request.getParameter("error");
if (error != null) { /* advisory — show UI message */ return; }

String code  = request.getParameter("code");
String state = request.getParameter("state");
// Verify state against the session value

// Sign the tagged code string
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initSign(loadPrivateKey("/path/sp_priv.pem"));
sig.update(("beid-code-v1:" + code).getBytes(StandardCharsets.US_ASCII));
String sigB64 = Base64.getEncoder().encodeToString(sig.sign());

// Exchange code for result_token (server-to-server)
HttpClient http = HttpClient.newHttpClient();
HttpResponse<String> resp = http.send(
    HttpRequest.newBuilder()
        .uri(URI.create("https://beid.hermesplatform.be/hosted/token.php"))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .POST(HttpRequest.BodyPublishers.ofString(
            "code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) +
            "&sp_id=your_sp_id&sp_sig_alg=ES256&sp_signature=" +
            URLEncoder.encode(sigB64, StandardCharsets.UTF_8)))
        .build(),
    HttpResponse.BodyHandlers.ofString());
String resultToken = new ObjectMapper()
    .readTree(resp.body()).get("result_token").asText();

// Verify result_token
SignedJWT jwt = SignedJWT.parse(resultToken);
if (!jwt.verify(new ECDSAVerifier(JWK.parseFromPEMEncodedObjects(pubPem).toECKey())))
    throw new Exception("invalid signature");
String payloadJson = new String(
  Base64.getDecoder().decode(jwt.getJWTClaimsSet().getStringClaim("payload_b64")),
  StandardCharsets.UTF_8);

9) Registration

Contact the platform administrator. Provide at registration: