About HMAC authentication

HMAC

🚧

Be Aware!

  • curly braces represent variables, do not include curly braces verbatim

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);
📘

Sample header with HMAC

'Authorization': 'HMAC client_id="someclient",ts="1616494592",nonce="G9aGfYcjqMtxUIxbsQAcEHQlaba7cFBrZjknC74qEjA",signature="mx1NJbC0Erj4a+Ojiscf4gxzdDARIm9lEofn3D6I7YswQPCSQY9dx8nspek14ZJuLTlW6IyaH7oSYbIweFzS6A=="'}