Key takeaways
- HMAC combines a secret key with a hash such as SHA-256, so only someone with the key can produce a code that matches the message.
- Use HMAC instead of hashing the secret and the message together, because SHA-256 on its own allows length extension attacks.
- Verify by rebuilding the exact signed bytes, computing the HMAC on your server, and comparing with hash_equals, timingSafeEqual (after a length check), or compare_digest.
What is HMAC?
HMAC (hash-based message authentication code) is a short code computed from a message and a secret key, using a hash function such as SHA-256. The sender attaches the code to the message, and the receiver, who has the same key, computes it again and compares. If the two match, the message came from someone who knows the key and was not changed on the way. HMAC is defined in RFC 2104 and standardized by NIST in FIPS 198-1.
You meet HMAC every time a service signs a webhook, a postback, or a link. Below: how it works, why it beats a plain hash, and tested verification code in PHP, Node.js, and Python.
How HMAC works, in plain English
A message authentication code (MAC) answers one question: “Did this message come from someone who holds our shared secret, unchanged?” It is like a wax seal that only you and one partner can make. Anyone can read the letter, but nobody else can forge the seal, and breaking the letter open breaks the seal.
HMAC builds that seal from an ordinary hash function. RFC 2104 defines it as:
HMAC(K, text) = H( (K XOR opad) || H( (K XOR ipad) || text ) )
- H is the hash function, for example SHA-256. With SHA-256 you call it HMAC-SHA256.
- K is the secret key, padded to the hash’s block size (64 bytes for SHA-256). RFC 2104 says keys longer than the block size are hashed first.
- ipad and opad are fixed constants: the byte
0x36and the byte0x5C, repeated to the block size. - || means “joined together”.
So HMAC hashes twice: once over the key mixed with the message, and once more over the key mixed with that result. You never implement this yourself. Every language ships a tested HMAC function; you choose the hash, the key, and the exact bytes to sign.
HMAC-SHA256 always produces 32 bytes. Most APIs send them as 64 lowercase hex characters, some as base64. The length never depends on the message, so a one-character user ID and a 2 MB JSON body both give 64 hex characters.
HMAC vs hash vs digital signature
People often say “hash” when they mean HMAC. They are different tools:
| Tool | Uses a key? | What it proves | Typical use |
|---|---|---|---|
| Plain hash (SHA-256) | No | The data was not changed by accident. Anyone can compute it. | File checksums, cache keys |
Hash of secret + message, SHA256(secret || message) | Yes, but glued on | Looks like a signature, but SHA-256 lets attackers extend the message | Old API signing schemes. Avoid. |
| HMAC-SHA256 | Yes, one shared secret | Someone with the key sent this exact message | Webhooks, postbacks, signed links, API requests |
| Digital signature (Ed25519, RSA, ECDSA) | Yes, private key signs, public key verifies | The private key holder signed it, and anyone with the public key can check | Software releases, certificates, tokens checked by many parties |
| Encryption (AES) | Yes | Nothing about who sent it, on its own. It hides the content. | Keeping data secret |
Two points matter most. HMAC hides nothing: the message travels in plain sight, so use HTTPS for privacy. And a shared secret means both sides can sign. That is fine between two parties, such as a network and a publisher, but if many parties must verify without being able to sign, use a digital signature.
Why not just hash the secret and the message?
SHA-256, SHA-1, and MD5 process data in blocks and carry an internal state from one block to the next. The final hash is that state. If a scheme signs requests as SHA256(secret || message), an attacker who sees one valid signature and knows (or guesses) the length of the secret can continue hashing from that state. They can append their own parameters and produce a valid signature for the longer message, without ever learning the secret. This is a length extension attack.
MITRE’s attack catalog lists it as CAPEC-461, web services API signature forgery. Its reference case is the 2009 Flickr API signature forgery, and the only mitigation it lists is a proper MAC such as HMAC. HMAC’s second, outer hash with the key is what stops the attack: the value an attacker sees is not a state they can continue from.
How signing and verifying work
Every HMAC scheme has the same shape. Providers differ only in which bytes they sign and where the signature travels.
- Both sides share a key once, over a safe channel, such as a dashboard behind your login.
- The sender builds the exact message to sign. That can be the raw request body, or a few fields joined in a fixed order with a separator.
- The sender computes the HMAC and sends it with the message, in a header, a query parameter, or a form field.
- The receiver rebuilds the same message from the values exactly as they arrived, computes its own HMAC with its copy of the key, and compares the two in constant time.
- On a mismatch, the receiver rejects the request (for example with
401or403) and changes nothing.
What a valid HMAC does not prove
It does not prove the request is new. Someone who captures a signed request can send the exact same request again, and the signature still matches. Defend against replays with a timestamp inside the signed message, a one-time ID, or idempotent processing. For rewards, that means applying each transaction ID once: see how to prevent duplicate reward credits.
How to verify an HMAC signature: PHP, Node.js, Python
The examples below verify a generic webhook: the sender signs the raw request body with HMAC-SHA256 and sends lowercase hex in an X-Signature header. Each one rejects a missing, short, wrong, or tampered signature. We ran all three locally against the same requests (PHP 8.4, Node.js 22, Python 3.12) before publishing. Adjust the header name and the message to your provider’s docs.
PHP: hash_hmac and hash_equals
<?php
function verify_signature(string $body, string $given, string $secret): bool
{
if ($secret === '') {
return false;
}
$expected = hash_hmac('sha256', $body, $secret);
return hash_equals($expected, strtolower(trim($given)));
}
$secret = getenv('WEBHOOK_SECRET') ?: '';
$body = file_get_contents('php://input');
$given = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!verify_signature($body, $given, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
http_response_code(200);
The PHP manual for hash_hmac says it returns lowercase hex unless you pass true as the fourth argument, which returns raw bytes. The hash_equals page says the user-supplied string must be the second argument, and that strings of different lengths return false right away. The empty-secret check matters: a missing environment variable would otherwise let anyone sign with an empty key.
Node.js: createHmac and timingSafeEqual
const crypto = require('node:crypto');
const http = require('node:http');
const SECRET = process.env.WEBHOOK_SECRET;
if (!SECRET) throw new Error('WEBHOOK_SECRET is not set');
function verifySignature(body, given, secret) {
if (typeof given !== 'string') return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(body).digest('hex'));
const received = Buffer.from(given.trim().toLowerCase());
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks);
if (!verifySignature(body, req.headers['x-signature'], SECRET)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(body.toString('utf8'));
res.writeHead(200).end();
});
}).listen(3000);
The length check before timingSafeEqual is not optional. The Node.js crypto docs say an error is thrown if the two inputs have different byte lengths (in our test, a RangeError with code ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH). Without the check, a request with a short signature crashes your handler instead of getting a clean 401. The same docs warn that timingSafeEqual does not make the surrounding code timing-safe. If you use Express, read the body with express.raw(), so you sign the bytes that arrived, not a re-serialized object.
Python: hmac.new and compare_digest
import hashlib
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"].encode("utf-8")
def verify_signature(body: bytes, given: str, secret: bytes) -> bool:
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), given.strip().lower().encode())
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify_signature(body, self.headers.get("X-Signature", ""), SECRET):
self.send_response(401)
self.end_headers()
return
event = json.loads(body)
self.send_response(200)
self.end_headers()
HTTPServer(("", 8000), Handler).serve_forever()
The Python hmac docs say digestmod is required and recommend compare_digest() instead of == for verification. They also say that if you compare two str values, both must be ASCII. In our test, a header with one non-ASCII character raised a TypeError, so the example compares bytes instead. In Flask, read the raw body with request.get_data().
Check your setup with an official test vector
RFC 4231 publishes known answers for HMAC-SHA256. Its test case 2 uses the key Jefe and the message what do ya want for nothing?. If your code prints the value below, your HMAC call, your encoding, and your hex output are right:
php -r "echo hash_hmac('sha256', 'what do ya want for nothing?', 'Jefe');"
5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
Why you must compare in constant time
A normal comparison such as == or === stops at the first character that differs. A wrong signature that starts with the right three characters takes slightly longer to reject than one that is wrong from the first character. The PHP manual describes exactly this leak. With enough requests, an attacker can measure those tiny differences and guess the correct signature for a forged message one character at a time.
Constant-time functions compare every byte no matter where the first difference is: hash_equals() in PHP, crypto.timingSafeEqual() in Node.js, and hmac.compare_digest() in Python. Use them for every signature, token, and API key comparison.
Common HMAC mistakes and how to fix them
Most “signature does not match” bugs come from signing different bytes than the sender did.
| Mistake | What happens | Fix |
|---|---|---|
| Comparing hex with raw bytes | A 64-character hex string never equals 32 raw bytes | Compare the same format the sender used: hex with hex, base64 with base64 |
| Uppercase vs lowercase hex | A1B2 and a1b2 are different strings | Normalize the received value to lowercase, as in the examples |
| Re-encoding the payload | Parsing JSON and encoding it again changes spacing or key order | Sign the raw body exactly as it arrived |
| Reformatting values | Casting 10.50 to a number turns it into 10.5 | Hash the strings as received, then convert |
| Wrong key encoding | A key meant to be decoded from hex or base64 is used as text, or the other way round | Follow the provider’s docs: some keys are plain strings, some are encoded bytes |
| Wrong key | Using a test key in production, or one product’s key for another | Name keys clearly in your config and load each from its own variable |
| Different lengths in Node.js | timingSafeEqual throws and your handler crashes | Check lengths first and treat a mismatch as invalid |
Plain == comparison | Timing leak | Use the constant-time function of your language |
| Logging secrets | Keys end up in log files, error trackers, and backups | Log that a check failed and the request ID, never the key |
| Signing on the client | Anyone can pull the key out of JavaScript or an app bundle | Compute HMACs only on your server |
Debug a mismatch in two minutes
Log the exact message string you sign (not the key) next to the signature you received. Then compute the HMAC of that string by hand with the RFC 4231 command above, swapping in your key and message. If your hand result matches the received signature, your code changes the message before signing. If it does not, you have the wrong key or the wrong field order.
Key length, storage, and rotation
RFC 2104 strongly discourages keys shorter than the hash output, so for HMAC-SHA256 use at least 32 random bytes. If you create the key yourself, generate it with a cryptographic random source, for example bin2hex(random_bytes(32)) in PHP, crypto.randomBytes(32).toString('hex') in Node.js, or secrets.token_hex(32) in Python. Never use a password, a user ID, or anything a person picked.
Store keys in environment variables or a secrets manager, never in your repository. Rotate a key when someone who knew it leaves, when it may have leaked, and on a regular schedule if your policy asks for one.
When you control both sides, rotate without downtime by accepting the old and the new key for a short overlap:
function verify_with_any_key(string $body, string $given, array $keys): bool
{
foreach ($keys as $key) {
if ($key !== '' && hash_equals(hash_hmac('sha256', $body, $key), $given)) {
return true;
}
}
return false;
}
$keys = [getenv('SIGNING_KEY_NEW') ?: '', getenv('SIGNING_KEY_OLD') ?: ''];
Remove the old key once the sender has switched. When a provider manages the key, follow its rotation rules. On Sharklio there is no overlap: the old key stops working right away, so deploy the new one at once. How to rotate your keys and link salt walks through it.
How Sharklio uses HMAC
Sharklio uses HMAC-SHA256 in two places, each with its own secret from the Integration tab of your app. Mixing the two up is the most common integration mistake.
| Offerwall link hash | Postback signature | |
|---|---|---|
| Who signs | Your server | Sharklio |
| Who verifies | Sharklio | Your server |
| Key | Link hash salt | Postback secret key |
| Signed message | The user ID | Transaction ID, user ID, reward, and status joined with colons |
| Output | Lowercase hex in hash | Lowercase hex in the {hash} macro |
| What it stops | Opening the wall as another user by editing user_id | Forged or edited reward calls |
The offerwall link hash
The offerwall link docs define the link as https://wall.sharklio.com/{APP_ID}?user_id={USER_ID}&hash={HASH}, where the hash is the HMAC-SHA256 of the user ID with your link hash salt, as lowercase hex. Build it on your server and send only the finished link to the browser:
$hash = hash_hmac('sha256', $userId, $linkHashSalt);
$url = 'https://wall.sharklio.com/' . $appId . '?user_id=' . rawurlencode($userId) . '&hash=' . $hash;
Link security is on by default. A missing hash or one that does not match the user ID shows a Link not valid page (HTTP 403). The optional sub1 to sub5 tracking values are not part of the hash, so use them for tracking only.
The postback signature
The postback docs define {hash} as the HMAC-SHA256, as lowercase hex, of the transaction ID, user ID, reward, and status joined with colons, signed with your postback secret key. Use the values exactly as they arrive:
$expected = hash_hmac('sha256', $tx . ':' . $userId . ':' . $reward . ':' . $status, $postbackSecretKey);
if (!hash_equals($expected, $hash)) {
http_response_code(403);
exit;
}
Only those four values are signed. Macros such as {payout} or {country} are fine for logs, but credit the signed {reward}. Sharklio also publishes the IP addresses postbacks come from, and the docs treat an IP allow-list as an extra layer only: always verify the hash as well.
Illustrative example, with made-up keys you can use to test your code: with the link hash salt example_link_salt and user ID 1234, the link hash is e8879dbc53a517476f5d365e4a9c1dfb238cf41a8c98d07330e2e34a30c373f0. With the postback secret key example_postback_key, the message tx_9f2c:1234:0.296:1 signs to 5441c6eeb66eba2f5a4e86986bac51f99ef2ce10262b4e6e370f3cb8bc95b072. If your code gets the same values, swap in your real keys.
For the full handler with database locking, see postback security. For the concept, see what is a postback URL, and for how postbacks differ from webhooks, webhook vs postback. Ready-made handlers are in offerwall postbacks in Node.js and in Python. Before going live, use Send a test postback, as shown in how to test your app. Docs checked on October 22, 2026.
Signed at both ends on Sharklio
Sharklio is a pay-per-result affiliate network for websites, apps, games, and communities. Your server signs each offerwall link with your link hash salt, and every credit and every reversal reaches your server as a server-to-server postback signed with your postback secret key, so you only credit rewards you can verify. Every publisher application is reviewed by hand. See how the wall works, or get in touch with questions about your integration.
Frequently asked questions
Is HMAC encryption?
No. HMAC proves that a message is genuine and unchanged, but the message itself stays readable. Use HTTPS or encryption if the content must be private.
What is the difference between HMAC and SHA-256?
SHA-256 is a hash function that anyone can compute over any data. HMAC-SHA256 uses SHA-256 together with a secret key, so only someone with the key can produce a matching code.
Can an HMAC be decoded or reversed?
No. An HMAC is a one-way code, so you cannot get the message or the key back from it. You verify it by computing the HMAC again from the message and the key and comparing.
How long should an HMAC-SHA256 key be?
At least 32 random bytes. RFC 2104 strongly discourages keys shorter than the hash output, which is 32 bytes for SHA-256.
Should an HMAC be sent as hex or base64?
Either works, as long as both sides agree. Hex is 64 characters for HMAC-SHA256 and base64 is 44, and you must compare in the same format the sender used.
Why does my HMAC signature not match?
Usually the bytes you sign differ from the sender’s: a re-encoded JSON body, a reformatted number, the wrong field order, or the wrong key. Log the exact message you sign and compare it with the provider’s documented format.
Does HMAC protect against replay attacks?
Not on its own. A captured request can be sent again with the same valid signature, so add a timestamp or one-time ID to the signed message, or process each transaction ID only once.