Developer · Internal tools
Integration guide for Service Providers (SPs). Machine-readable version: integration.md. Want to try your integration? Use the Test-SP.
As an SP you talk only to the beID Hermes Platform. The user does not have to manage the local helper themselves.
payload_b64."beid-start-v1:" + payload_b64 with your private key.return_url?code=<opaque>&state=<state>.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.
Use this exact endpoint address:
POST fields:
| 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 (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:
| field | required | description |
|---|---|---|
sp_id | yes | The config key assigned at registration (e.g. blancefloer_intranet). This is the object name in the platform configuration — not a password or certificate. |
op | yes | Exactly one operation per request (see the list below). A value like "read:identity read:certs" is an error. |
return_url | yes | Must match a registered base URL on your SP's allowlist. An allowlist is mandatory; without one the request is rejected (see return_url allowlist). |
state | no | Opaque string, returned unchanged in the redirect URL and the result JWT. Use it to tie the result to the user session. |
ts | yes | Unix timestamp in seconds. Requests older than 5 minutes are rejected. |
nonce | yes | Random unique 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 already-computed digest. |
digest_alg | no (auth/sign) | "SHA256" or "SHA384" (default "SHA384"). Selects the hash algorithm. |
prehashed | no (auth/sign) | false (default) — the 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 | Personal data: name, national number, date of birth, gender, … (no address) |
read:address | Address: street, postal code, municipality. A separate call from read:identity. |
read:identity+address | Read identity and address in one card session. Returns a nested result object. |
read:photo | ID photo (JPEG). |
read:certs | All certificates on the card. |
auth | Sign bytes with the authentication key. |
sign | Sign bytes with the non-repudiation key. |
Signature rules:
"beid-start-v1:" + payload_b64 (the literal prefix beid-start-v1: followed by the base64 string — not the JSON bytes). The prefix is a domain-separation tag that distinguishes this signature from the code-exchange signature (beid-code-v1:); signing the wrong string yields invalid signature.sp_cert_pem.sp_sig_alg must match what is registered with the platform.return_urlOn 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.
Your backend POSTs to token.php:
| field | description |
|---|---|
code | Opaque code from the redirect URL. |
sp_id | Your SP identifier. |
sp_sig_alg | Same algorithm as the start request (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: 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>" }
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.
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.
On cancellation, PIN error, no card or server error the platform redirects to:
GET https://sp.example/return?error=<code>&state=a3f9c2e1d4b87650
| error code | cause |
|---|---|
access_denied | User cancelled or refused |
pin_failed | Wrong PIN (best-effort detection) |
no_card | No card / reader / token present |
helper_unavailable | Helper not installed or unreachable |
helper_outdated | Helper too old for the requested operation |
server_error | Internal error on the platform side |
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).
return_url allowlistEvery 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.
https://sp.example/cb allows: https://sp.example/cb, https://sp.example/cb?session=x, etc.state (not URL parameters) for session correlation; state is returned in the redirect and in the signed JWT.Fields marked omitempty are absent from the JSON when the card returns an empty value.
| field | type | format / note |
|---|---|---|
national_number | string | National register number |
surname | string | |
given_names | string | All given names, space-separated |
third_given_initial | string | Initial of the third given name |
birth_date | string | YYYY-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_location | string | |
gender | string | Value as on the 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 (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.
| field | type | format / note |
|---|---|---|
street_and_number | string | Street and house number combined |
zip | string | Postal code — not postal_code |
municipality | string |
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.
/enc/read/person endpoint. If the installed helper is too old, the platform returns an error redirect with error=helper_outdated.
| field | type | note |
|---|---|---|
mime | string | Always image/jpeg |
photo_b64 | string | base64-encoded JPEG |
Array certs, per item:
| field | type | note |
|---|---|---|
label | string | 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) |
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. |
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:
| field | type | note |
|---|---|---|
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 (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 |
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.
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.
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.
<?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>
<?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'], ...
?>
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));
// 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));
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());
// 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);
Contact the platform administrator. Provide at registration:
sp_id (becomes the config key in every request)sp_cert_pem) — used to verify your start-request and token-exchange signaturesreturn_url base(s) the platform may redirect to (register the exact scheme/host/path; query parameters are matched permissively)ES256 or RS256)