# beID Hermes Platform — SP Integration Guide Base URL: `https://beid.hermesplatform.be` ## Quick start > **Start endpoint — use this exact URL:** > > ``` > POST https://beid.hermesplatform.be/hosted/start.php > ``` Flow: 1. SP builds a JSON payload with a **single** operation. 2. SP base64-encodes the JSON → `payload_b64`. 3. SP signs the context-tagged string `"beid-start-v1:" + payload_b64` with its private key. 4. SP does a POST-redirect to `https://beid.hermesplatform.be/hosted/start.php`. 5. The platform reads the card and **302-redirects** the browser to `return_url?code=&state=`. 6. The SP **backend** exchanges the `code` for a `result_token` via a server-to-server POST to `token.php`. On error (user cancels, wrong PIN, no card, helper missing), the platform redirects to `return_url?error=&state=` — see [Error callbacks](#error-callbacks). ## Start request (SP → Platform) ``` POST https://beid.hermesplatform.be/hosted/start.php Content-Type: application/x-www-form-urlencoded ``` | Field | Description | |---|---| | `payload_b64` | base64(JSON) of the start payload (see below) | | `sp_sig_alg` | `ES256` or `RS256` | | `sp_signature` | base64(signature over `"beid-start-v1:" + payload_b64`) | ### Payload JSON ```json { "sp_id": "blancefloer_intranet", "op": "read:identity", "return_url": "https://sp.example/return", "state": "opaque123", "ts": 1700000000, "nonce": "a3f9c2e1d4b87650", "tbs_b64": "..." } ``` | Field | Required | Description | |---|---|---| | `sp_id` | yes | The config key assigned at registration (e.g. `blancefloer_intranet`). This is the object name in the platform registry — not a certificate or password. | | `op` | yes | **Exactly one** operation per request (see list below). Values like `"read:identity read:certs"` are invalid. | | `return_url` | yes | Must match a registered base URL on the SP's allowlist. An allowlist is **required**; a request is rejected if your SP has none (see [return_url allowlist](#return_url-allowlist)). | | `state` | no | Opaque string, echoed unchanged in the redirect URL and the result JWT. Use it to tie the result back to the user session. | | `ts` | yes | Unix timestamp in seconds. Requests that are too old are rejected. | | `nonce` | yes | Unique random value per request (anti-replay). | | `tbs_b64` | yes for `auth`/`sign` | base64 of the bytes to sign. With `prehashed=false` (default) this is the raw message; with `prehashed=true` this is the pre-computed digest. | | `digest_alg` | no (`auth`/`sign` only) | `"SHA256"` or `"SHA384"` (default `"SHA384"`). Selects the hash algorithm. | | `prehashed` | no (`auth`/`sign` only) | `false` (default) — helper hashes `tbs_b64` with `digest_alg`; `true` — `tbs_b64` is already the digest (32 bytes for SHA256, 48 for SHA384). | ### Valid values for `op` | Value | Description | |---|---| | `read:identity` | Read name, national number, birth date, gender, … from the card. | | `read:address` | Read street, zip code, municipality. **Separate call from `read:identity`.** | | `read:identity+address` | Read identity and address in a single card session. Returns a nested result object. | | `read:photo` | Read the passport photo (JPEG). | | `read:certs` | Read all certificates on the card. | | `auth` | Sign bytes with the authentication key (eID authentication certificate). | | `sign` | Sign bytes with the non-repudiation key (eID signature certificate). | ### Signature rules - Sign the **ASCII string** `"beid-start-v1:" + payload_b64` (the literal prefix `beid-start-v1:` followed by the base64 string — not the decoded JSON bytes). The prefix is a domain-separation tag that keeps this signature distinct from the code-exchange signature (`beid-code-v1:`); signing the wrong string yields `invalid signature`. - Use the SP private key that corresponds to the `sp_cert_pem` registered with the platform. - `sp_sig_alg` must match what is registered for your SP. ## Response: redirect and token exchange ### Step 1 — Platform redirects to your `return_url` On success, the platform **302-redirects** the browser to: ``` GET https://sp.example/return?code=&state=opaque123 ``` This is a top-level GET navigation, so `SameSite=Lax` session cookies are forwarded correctly — no special cookie configuration is needed. ### Step 2 — Exchange the code for the result token (server-to-server) Your **backend** calls `token.php`: ``` POST https://beid.hermesplatform.be/hosted/token.php Content-Type: application/x-www-form-urlencoded code=&sp_id=blancefloer_intranet&sp_sig_alg=ES256&sp_signature= ``` | Field | Description | |---|---| | `code` | Opaque code received in the redirect. | | `sp_id` | Your SP identifier. | | `sp_sig_alg` | Same algorithm as used for start (`ES256` or `RS256`). | | `sp_signature` | base64(signature over `"beid-code-v1:" + code`) — same key and algorithm as the start request, but with the `beid-code-v1:` domain-separation prefix (note: a **different** prefix than the start request's `beid-start-v1:`). | The code is **single-use** with a ~120 second TTL and is bound to your `sp_id`. Successful response (200 OK, `Content-Type: application/json`): ```json { "result_token": "" } ``` Error response (4xx, `Content-Type: application/json`): ```json { "error": "" } ``` ### Validating result_token Verify the JWT signature using the platform public key: ``` https://beid.hermesplatform.be/keys/challenge_pub.pem ``` Algorithm: `ES256` (ECDSA P-256 with SHA-256). ### Result token claims ```json { "iss": "beid.hermesplatform.be", "aud": "sp", "sp_id": "blancefloer_intranet", "op": "read:identity", "status": "ok", "state": "opaque123", "payload_b64": "", "result_hash": "", "iat": 1700000000, "exp": 1700000300, "jti": "" } ``` `status` is always `"ok"` in a successfully exchanged token — decode `payload_b64` to get the result data. > **`state` is echoed in both the redirect URL and the JWT claims.** Read it from the JWT claims (where it is platform-signed). Compare it to the value stored in your session before the redirect to detect CSRF. > **Naming note — `payload_b64` in the result JWT:** This claim contains the **result data** (identity, address, signature, …) encoded as base64(JSON). It is *not* the request payload you sent in the start request. The name reuses the same field name used in the start request, but the content is entirely different at this point in the flow. ## Error callbacks On cancellation, PIN failure, no card, or server error, the platform **302-redirects** to: ``` GET https://sp.example/return?error=&state=opaque123 ``` | Error code | Cause | |---|---| | `access_denied` | User cancelled or declined | | `pin_failed` | Wrong PIN (best-effort detection) | | `no_card` | No card / reader / token present | | `helper_unavailable` | Helper not installed or not reachable | | `helper_outdated` | Helper too old for the requested operation | | `server_error` | Platform-side decrypt / sign / internal failure | > **Security note:** Error callbacks are **advisory and unauthenticated** — anyone can craft a URL 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 allowlisted, so no open redirect is possible). ## `return_url` allowlist Every SP **must** have at least one allowlisted return URL. A start request whose SP has no allowlist configured is rejected — there is no implicit "allow any URL" fallback. Registration allowlists the **base** of a URL: `scheme://host[:port]/path`. A request `return_url` is accepted when its base (stripped of query string and fragment) exactly matches a registered base entry. **Query parameters are allowed on a registered base** — you may include query parameters in the `return_url` field of your start payload, as long as the scheme, host, and path match exactly. - Example: registering `https://sp.example/cb` permits `https://sp.example/cb`, `https://sp.example/cb?session=x`, etc. - The path must match exactly — no prefix matching or subdirectory escalation is permitted. - Use `state` (not URL parameters) to carry session-correlation data; `state` is echoed in the redirect and signed in the JWT. ## Operations and response fields All fields use `omitempty`: a field is absent from the JSON if the card returns an empty value. ### read:identity | Field | Type | Format / notes | |---|---|---| | `national_number` | string | Belgian national register number | | `surname` | string | | | `given_names` | string | All first names, space-separated | | `third_given_initial` | string | Initial of third given name | | `birth_date` | string | `YYYY-MM-DD` (ISO 8601) when the card carries a full date. Rare older cards may carry a partial value (e.g. year-only) verbatim — parse defensively. | | `birth_location` | string | | | `gender` | string | Value as stored on card (`M`, `F`, …) | | `nationality` | string | | | `card_number` | string | | | `validity_begin_date` | string | `DD.MM.YYYY` | | `validity_end_date` | string | `DD.MM.YYYY` | | `issuing_municipality` | string | | > **Address data is not included.** Street, zip code, and municipality require a separate `read:address` call — or use `read:identity+address` to read both in one card session. ### read:address | Field | Type | Format / notes | |---|---|---| | `street_and_number` | string | Street name and house number combined | | `zip` | string | **Not** `postal_code` | | `municipality` | string | | ### read:identity+address Reads identity and address in a **single card session** (one PIN prompt). The `payload_b64` in the result token decodes to a nested object: ```json { "identity": { "national_number": "...", "surname": "...", "given_names": "...", "birth_date": "YYYY-MM-DD", "..." }, "address": { "street_and_number": "...", "zip": "...", "municipality": "..." } } ``` Fields within `identity` and `address` are identical to those returned by the separate `read:identity` and `read:address` operations. > **Note:** This operation requires a helper version that supports the `/enc/read/person` endpoint. If the installed helper is too old, the platform returns an error redirect with `error=helper_outdated`. ### read:photo | Field | Type | Notes | |---|---|---| | `mime` | string | Always `image/jpeg` | | `photo_b64` | string | base64-encoded JPEG | ### read:certs Response contains a `certs` array. Each entry: | Field | Type | Notes | |---|---|---| | `label` | string | Certificate label (e.g. `Authentication`, `Signature`) | | `id_hex` | string | `CKA_ID` as hex | | `cert_der_b64` | string | base64-encoded DER X.509 certificate | | `cert_fp_sha256` | string | SHA-256 fingerprint (hex) | ### 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): | Field | Default | Description | |---|---|---| | `digest_alg` | `"SHA384"` | Hash algorithm: `"SHA256"` or `"SHA384"`. | | `prehashed` | `false` | If `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. | Using `digest_alg: "SHA256"` 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 — verified on a card whose auth > certificate is valid from 2018-04-24, so this is a large share of the active > population, not a "pre-2015" edge case.) > > The helper selects the signing algorithm automatically from the card key's > type — no SP action required. The response reports which was used: > > | Card | `signature_format` | `mechanism` | Signature bytes | > |---|---|---|---| > | newer | `der` | `ECDSA-SHA384` / `ECDSA-SHA256` | ASN.1 DER ECDSA | > | older | `pkcs1` | `RSA-SHA256` / `RSA-SHA384` | RSASSA-PKCS1-v1_5 (256 B) | > > Both `digest_alg: "SHA256"` and `"SHA384"`, and both `prehashed: false` and > `prehashed: true`, work on both generations. Verify the signature against the > public key in the returned certificate using the algorithm in `mechanism`. Result fields: | Field | Type | Notes | |---|---|---| | `signature_format` | string | `"der"` (ECDSA cards) or `"pkcs1"` (RSA cards) | | `signature_b64` | string | base64-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_b64` | string | base64-encoded DER X.509 certificate of the signing key | | `cert_fp_sha256` | string | SHA-256 fingerprint of the certificate (hex) | | `key` | string | `"auth"` or `"sign"` | | `mechanism` | string | `"ECDSA-SHA256"`/`"ECDSA-SHA384"` (ECDSA cards) or `"RSA-SHA256"`/`"RSA-SHA384"` (RSA cards), depending on card type and `digest_alg` | ### 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`: ```php // $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. ## Implementation notes ### 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 with top-level GET navigations. No special cookie configuration is needed. Store a `state` value in your session before the redirect, and verify it matches the value in the redirect URL (and in the signed JWT claim) when the callback arrives. This prevents CSRF. ### Error and timeout handling On error or cancellation, the platform **redirects** to `return_url?error=&state=`. Your callback endpoint should handle both the success case (`code` parameter present) and the error case (`error` parameter present). Error redirects are advisory and unauthenticated. Only a successfully exchanged, signature-verified `result_token` obtained from `token.php` is authoritative. ## Code examples ### PHP — start (signed POST-redirect) ```php '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); ?>
``` ### PHP — callback (exchange code, verify result_token) ```php 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 result_token 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'], … ``` ### ASP.NET (C#) — start ```csharp 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")); var sigDer = ecdsa.SignData( System.Text.Encoding.ASCII.GetBytes("beid-start-v1:" + payloadB64), System.Security.Cryptography.HashAlgorithmName.SHA256); var sigB64 = Convert.ToBase64String(sigDer); ``` ### ASP.NET (C#) — callback (exchange code, verify result_token) ```csharp // Callback receives GET ?code=...&state=... or ?error=...&state=... var error = Request.Query["error"].ToString(); if (!string.IsNullOrEmpty(error)) return Problem("eID error: " + error); // advisory only var code = Request.Query["code"].ToString(); var state = Request.Query["state"].ToString(); // Verify state against the value stored in the session before the redirect // Sign the context-tagged code string for the token exchange var ecdsaSp = System.Security.Cryptography.ECDsa.Create(); ecdsaSp.ImportFromPem(File.ReadAllText("sp_priv.pem")); var sigB64 = Convert.ToBase64String( ecdsaSp.SignData(System.Text.Encoding.ASCII.GetBytes("beid-code-v1:" + code), System.Security.Cryptography.HashAlgorithmName.SHA256)); // 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 { ["code"] = code, ["sp_id"] = "your_sp_id", ["sp_sig_alg"] = "ES256", ["sp_signature"] = sigB64, })); var body = await resp.Content.ReadFromJsonAsync(); var token = body.GetProperty("result_token").GetString(); // Verify result_token var pubPem = File.ReadAllText("platform_pub.pem"); var ecdsa = System.Security.Cryptography.ECDsa.Create( System.Security.Cryptography.ECCurve.NamedCurves.nistP256); ecdsa.ImportFromPem(pubPem); var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); 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 = handler.ValidateToken(token, parms, out _); var payloadB64 = principal.Claims.First(c => c.Type == "payload_b64").Value; var result = System.Text.Json.JsonDocument.Parse( Convert.FromBase64String(payloadB64)); ``` ### Java — start ```java String payloadJson = new ObjectMapper().writeValueAsString(Map.of( "sp_id", "your_sp_id", // config key assigned at registration "op", "read:identity", // exactly one operation "return_url", "https://sp.example/return", "state", UUID.randomUUID().toString().replace("-", ""), "ts", Instant.now().getEpochSecond(), "nonce", UUID.randomUUID().toString().replace("-", "") )); String payloadB64 = Base64.getEncoder().encodeToString( payloadJson.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()); ``` ### Java — callback (exchange code, verify result_token, Nimbus JOSE + JWT) ```java // 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 value stored in the session before the redirect // Sign the context-tagged code string for the token exchange 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 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); JWK jwk = JWK.parseFromPEMEncodedObjects(pubPem); if (!jwt.verify(new ECDSAVerifier(jwk.toECKey()))) throw new Exception("invalid signature"); JWTClaimsSet claims = jwt.getJWTClaimsSet(); String payloadJson = new String( Base64.getDecoder().decode(claims.getStringClaim("payload_b64")), StandardCharsets.UTF_8); ``` ## Registration To register your SP, contact the platform operator. You will need to provide: - A unique `sp_id` (this becomes the config key you use in every request) - Your signing certificate (`sp_cert_pem`) — used to verify your start-request and token-exchange signatures - The `return_url` base(s) that the platform is allowed to redirect results to (register the exact scheme/host/path; query parameters are matched permissively) - Preferred signature algorithm (`ES256` or `RS256`)