HMAC Signatures

Sign and verify webhooks, cookies and links with HMAC-SHA256 so you can prove a message came from someone holding the shared key and was not altered.

SyntaxMac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(keyBytes, "HmacSHA256")); byte[] tag = mac.doFinal(messageBytes); boolean ok = MessageDigest.isEqual(tag, receivedTag);

A hash proves a message has not changed only if you trust where the hash came from. An HMAC (hash-based message authentication code) mixes a secret key into the hash, so only someone holding the key can produce a valid tag. If the message or the tag is altered, verification fails. Both sides share the same key, which is what separates HMAC from public-key signatures.

The API

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] tag = mac.doFinal(body);

The key should be at least 32 random bytes from SecureRandom, stored as a secret, not a word someone typed. doFinal returns 32 bytes for HmacSHA256 and resets the Mac, but like MessageDigest a Mac is not thread-safe: create one per call, which is cheap.

Verifying correctly

Recompute the tag over exactly the bytes you received and compare with MessageDigest.isEqual. String.equals and Arrays.equals stop at the first different byte, and in principle the timing tells an attacker how many leading bytes they got right. Constant-time comparison costs nothing, so always use it.

Webhooks

Payment providers and source hosts sign every webhook they send. GitHub, for example, puts sha256=<hex HMAC of the body> in the X-Hub-Signature-256 header. Your endpoint must:

  • Read the raw body bytes and verify those. Parsing the JSON and serialising it again changes spacing or key order and breaks the signature.
  • Reject old messages. A valid signed request can be replayed. Schemes that sign a timestamp with the body let you refuse anything older than a few minutes.
  • Return 401 for a bad signature before doing any work.

Mistakes people make

  • Building sha256(secret + message) by hand. SHA-256 is open to length extension: given that hash, an attacker can compute a valid hash for the message with extra data appended, without the secret. HMAC's construction prevents this.
  • Thinking a signature hides data. A signed cookie or token is readable by anyone; HMAC only proves integrity. Keep secrets out of it or encrypt separately.
  • One key for everything. Use separate keys for cookies, webhooks and links, so a leak or rotation affects one feature.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Verify the HMAC signature header on incoming payment webhooks before marking an order as paid.
  • Sign outgoing webhooks to customers' endpoints so they can check the events really came from your service.
  • Issue signed download links that encode a file id and expiry time the client cannot alter.

More examples

Checking against the RFC 4231 test vector

Published test vectors are the quickest way to prove your signing code is right before you integrate with a partner. A comparison with equals is fine here because nothing secret is being checked.

Example · java
void main() throws Exception {
    // Test case 2 from RFC 4231, the published HMAC-SHA256 test vectors.
    byte[] key = "Jefe".getBytes(StandardCharsets.US_ASCII);
    byte[] data = "what do ya want for nothing?".getBytes(StandardCharsets.US_ASCII);
    String expected = "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843";

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key, "HmacSHA256"));
    String actual = HexFormat.of().formatHex(mac.doFinal(data));

    IO.println(actual);
    IO.println("matches RFC 4231: " + actual.equals(expected));
}
// Output:
// 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
// matches RFC 4231: true

Timestamped signatures to stop replays

The timestamp is inside the signed bytes, so an attacker cannot refresh it on a captured request without breaking the signature. Signing the string 'timestamp.body' is the same idea several payment providers use.

Example · java
static final byte[] KEY = "demo-key-not-a-real-secret".getBytes(StandardCharsets.UTF_8);
static final Duration TOLERANCE = Duration.ofMinutes(5);

String sign(long timestamp, String body) throws GeneralSecurityException {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(KEY, "HmacSHA256"));
    byte[] tag = mac.doFinal((timestamp + "." + body).getBytes(StandardCharsets.UTF_8));
    return HexFormat.of().formatHex(tag);
}

String check(long timestamp, String body, String signature, Instant now) throws GeneralSecurityException {
    if (Duration.between(Instant.ofEpochSecond(timestamp), now).abs().compareTo(TOLERANCE) > 0) {
        return "401 too old";
    }
    boolean ok = MessageDigest.isEqual(sign(timestamp, body).getBytes(StandardCharsets.US_ASCII),
                                       signature.getBytes(StandardCharsets.US_ASCII));
    return ok ? "200 accepted" : "401 bad signature";
}

void main() throws Exception {
    Instant now = Instant.parse("2026-10-13T09:00:00Z");
    long sentAt = now.minusSeconds(30).getEpochSecond();
    String body = "{\"event\":\"order.shipped\",\"id\":981}";
    String sig = sign(sentAt, body);

    IO.println("fresh:    " + check(sentAt, body, sig, now));
    IO.println("replayed: " + check(sentAt, body, sig, now.plus(Duration.ofHours(1))));
    IO.println("new time: " + check(now.getEpochSecond(), body, sig, now));
}
// Output:
// fresh:    200 accepted
// replayed: 401 too old
// new time: 401 bad signature

A tamper-proof cookie value

The value stays readable, but any edit makes the tag mismatch, so the server can trust a cart id it gets back. Use this for values the client may see but must not change.

Example · java
static final byte[] KEY = "demo-cookie-key-not-a-real-secret".getBytes(StandardCharsets.UTF_8);
static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();

byte[] tag(String value) throws GeneralSecurityException {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(KEY, "HmacSHA256"));
    return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
}

String sign(String value) throws GeneralSecurityException {
    return value + "." + B64.encodeToString(tag(value));
}

Optional<String> unsign(String cookie) throws GeneralSecurityException {
    int dot = cookie.lastIndexOf('.');
    if (dot < 0) return Optional.empty();
    String value = cookie.substring(0, dot);
    byte[] expected = B64.encode(tag(value));
    byte[] actual = cookie.substring(dot + 1).getBytes(StandardCharsets.US_ASCII);
    return MessageDigest.isEqual(expected, actual) ? Optional.of(value) : Optional.empty();
}

void main() throws Exception {
    String cookie = sign("cart-7731");
    IO.println("Set-Cookie: cart=" + cookie);
    IO.println("valid:    " + unsign(cookie));
    IO.println("edited:   " + unsign(cookie.replace("7731", "7732")));
    IO.println("unsigned: " + unsign("cart-7731"));
}
// Output:
// Set-Cookie: cart=cart-7731.CsIqOQ4z-90dHsgxkVIp83E-VnUHfpWT6qQATlu7OGk
// valid:    Optional[cart-7731]
// edited:   Optional.empty
// unsigned: Optional.empty

Discussion

  • Be the first to comment on this lesson.