About HMAC authentication
HMAC
HMAC - hash-based message authentication code
HMAC is computed as follows:
HTTP 'Authorization' header value is set to: 'HMAC {hmac_string}'
- {hmac_string} is: 'client_id="{client_id}",ts="{ts}",nonce="{nonce}",signature="{signature}"'
- client_id is your ZealiD Client ID
- ts is UNIX timestamp (integer), for replay and other attack vector mitigation
must be accurate and generated at the time the request is made
see Wiki - nonce is a random string to prevent replay attacks
64 characters are recommended, and base64 encoding can for example be used - signature is the HMAC signature, computed using SHA512 and base64 encoded
Signature is computed on the following string (let's call it auth_string)
auth_string: '{client_id}{nonce}{ts}{request_string}{payload}'- client_id, nonce and ts are as described above
- request_string is '{request_method} {full_path}'
- request_method is the HTTP request method in capitals, e.g. GET, POST, etc.
- full_path is HTTP path with base URL excluded, e.g. '/mediator/api/get_token'; if there are any query parameters, they must be included in full, and must exactly match the URL you are requesting the resource with, exactly; e.g. '/mediator/api/something?param=1'
- example of request_string: 'GET /mediator/api/get_token'
- payload is the body of your POST request, if any; this is empty string if no body is sent
- HMAC signature is then computed as:
BASE64(HMAC-SHA512(client_secret, auth_string))
Code Examples
<?php
/**
* ZealiD HMAC authentication client.
*
* Implements the request-signing scheme from
* https://developer.zealid.com/docs/about-hmac-authentication
*
* Header shape (example from the docs):
* Authorization: HMAC client_id="someclient",ts="1616494592",
* nonce="G9aGfYcjqMtxUIxbsQAcEHQlaba7cFBrZjknC74qEjA",
* signature="mx1NJbC0Erj4a+Ojiscf4gxzdDARIm9lEofn3D6I7YswQPCSQY9dx8nspek14ZJuLTlW6IyaH7oSYbIweFzS6A=="
*
* String-to-sign (no separators between the four parts — the only space in
* the whole string is the one inside request_string, between METHOD and path):
*
* auth_string = client_id . nonce . ts . request_string . payload
* request_string = "{METHOD} {full_path}" e.g. "GET /mediator/api/get_token"
* - method uppercase
* - full_path = path, plus "?" + query string if there is one
* - never a bare trailing "?" when there's no query
* payload = the raw request body bytes, "" when there is none
* signature = base64( HMAC-SHA512(key = client_secret, msg = auth_string) )
*
* Server tolerance (not documented on the page, confirmed against the
* verifying service): ts must be an integer number of seconds within ~60s of
* the server clock, and nonce must be at least 32 characters. Every existing
* client uses 32 random bytes base64url-encoded (43 chars) for the nonce;
* this class does the same.
*
* Requires: ext-curl, ext-hash (both are standard/bundled PHP extensions).
*/
final class ZealidHmacClient
{
private string $clientId;
private string $clientSecret;
private string $baseUrl;
public function __construct(string $clientId, string $clientSecret, string $baseUrl)
{
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
// Scheme + host (+ optional path prefix). NOT part of the signed
// string — only the path (and query) that follows it is signed.
$this->baseUrl = rtrim($baseUrl, '/');
}
/**
* Build the `Authorization: HMAC ...` header value for one request.
*
* $path must be exactly the path (and, if present, "?query=string") that
* will be sent on the wire — sign it verbatim, do not re-encode it
* differently for the URL than for the signature or the digest won't
* match server-side. Build any query string once with http_build_query()
* before calling this method.
*
* $payload must be the exact bytes of the request body (already
* serialized), or '' if the request has no body. Serialize once, sign
* that string, and send that same string — never re-encode after signing.
*/
public function authorizationHeader(string $method, string $path, string $payload = ''): string
{
return self::signWith($this->clientId, $this->clientSecret, $method, $path, $payload, self::nonce(), time());
}
/**
* Pure signing function with nonce/ts passed in explicitly, so it can be
* exercised with fixed test vectors (see zealid_hmac_test.php) instead of
* the random nonce and current time that authorizationHeader() uses.
*/
public static function signWith(
string $clientId,
string $clientSecret,
string $method,
string $path,
string $payload,
string $nonce,
int $ts
): string {
$method = strtoupper($method);
$requestString = "{$method} {$path}";
// auth_string: client_id + nonce + ts + request_string + payload, no separators.
$authString = $clientId . $nonce . $ts . $requestString . $payload;
// Raw HMAC-SHA512 digest (the `true` flag is required — it returns raw
// binary output, not hex — then that raw digest is base64-encoded).
$rawSignature = hash_hmac('sha512', $authString, $clientSecret, true);
$signature = base64_encode($rawSignature);
return sprintf(
'HMAC client_id="%s",ts="%d",nonce="%s",signature="%s"',
$clientId,
$ts,
$nonce,
$signature
);
}
/**
* Sign and perform the request with cURL.
*
* @param array<string,string> $headers extra headers, e.g. ['Content-Type' => 'application/json']
* @return array{status:int,body:string}
*/
public function request(string $method, string $path, ?string $payload = null, array $headers = []): array
{
$payload ??= '';
$method = strtoupper($method);
$authHeader = $this->authorizationHeader($method, $path, $payload);
$headerLines = ['Authorization: ' . $authHeader];
foreach ($headers as $name => $value) {
$headerLines[] = "{$name}: {$value}";
}
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true,
]);
if ($payload !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
}
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL request failed: {$error}");
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => $body];
}
/**
* 32 random bytes, base64url-encoded without padding -> 43 chars.
* Matches secrets.token_urlsafe(32) used by ZealiD's own Python clients.
*/
private static function nonce(): string
{
$raw = random_bytes(32);
$b64 = base64_encode($raw);
$urlSafe = strtr($b64, '+/', '-_');
return rtrim($urlSafe, '=');
}
}
// ---------------------------------------------------------------------------
// Demo. Only runs when this file is executed directly (php zealid_hmac.php),
// not when it's require()'d as a library.
// ---------------------------------------------------------------------------
if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$clientId = getenv('ZEALID_CLIENT_ID') ?: 'your-client-id';
$clientSecret = getenv('ZEALID_CLIENT_SECRET') ?: 'your-client-secret';
$baseUrl = getenv('ZEALID_BASE_URL') ?: 'https://api.example.com';
$client = new ZealidHmacClient($clientId, $clientSecret, $baseUrl);
// GET with no query, no body.
$header = $client->authorizationHeader('GET', '/mediator/api/get_token');
echo "GET header:\n{$header}\n\n";
// GET with a query string: build it once, sign the exact same string.
$query = http_build_query(['foo' => 'bar', 'baz' => 'qux']);
$header = $client->authorizationHeader('GET', "/mediator/api/get_token?{$query}");
echo "GET (with query) header:\n{$header}\n\n";
// POST with a form-encoded body.
$payload = http_build_query(['field1' => 'value1', 'field2' => 'value2']);
$header = $client->authorizationHeader('POST', '/mediator/api/some_endpoint', $payload);
echo "POST header:\n{$header}\n\n";
// Uncomment to actually perform the calls against a real endpoint:
// $result = $client->request(
// 'POST',
// '/mediator/api/some_endpoint',
// $payload,
// ['Content-Type' => 'application/x-www-form-urlencoded']
// );
// echo "Status: {$result['status']}\nBody: {$result['body']}\n";
}
<?php
/**
* Golden-vector test for ZealidHmacClient::signWith().
*
* The test vector below is not invented — it's the frozen fixture from
* portal-zealid/tests/test_orpheus_sdk.py::test_hmac_generation, which mocks
* time.time() and secrets.token_urlsafe() to pin nonce/ts and asserts the
* resulting Authorization header byte-for-byte. Reproducing the same
* signature here in PHP proves this class implements the identical
* client_id + nonce + ts + "{METHOD} {path}" + payload construction.
*
* Run: php zealid_hmac_test.php
*/
require __DIR__ . '/zealid_hmac.php';
// From portal-zealid/tests/test_orpheus_sdk.py:
// CLIENT_ID = 'mocked_client_id'
// CLIENT_SECRET = 'mocked_client_secret'
// MOCKED_TIME = 1612362851
// MOCKED_NONCE = 'JVzSQpuGDekuKnrTWIg2coe8yMGPMOUAl53GgWvQaYI'
// Request under test: HERMES.get_documents('mocked_customer_id') with
// HERMES.url overridden to 'https://hermes-dev.zealid.com/mediator/api',
// so the signed path is '/mediator/api/user/mocked_customer_id/documents'
// (GET, no query, no body).
$clientId = 'mocked_client_id';
$clientSecret = 'mocked_client_secret';
$ts = 1612362851;
$nonce = 'JVzSQpuGDekuKnrTWIg2coe8yMGPMOUAl53GgWvQaYI';
$method = 'GET';
$path = '/mediator/api/user/mocked_customer_id/documents';
$payload = '';
$expected = 'HMAC client_id="mocked_client_id",ts="1612362851",nonce="JVzSQpuGDekuKnrTWIg2coe8yMGPMOUAl53GgWvQaYI"'
. ',signature="jibfLgbsTZ9cc4zbaAAQqTGjnfvcRwJRSeU+pR3D7VolY607aRaSKcrZKNe7xpgjxA3Lt8uSKxkA5kJgHQgBuw=="';
$actual = ZealidHmacClient::signWith($clientId, $clientSecret, $method, $path, $payload, $nonce, $ts);
echo "Expected: {$expected}\n";
echo "Actual : {$actual}\n";
if ($actual === $expected) {
echo "PASS\n";
exit(0);
}
echo "FAIL\n";
exit(1);
Updated 16 days ago
