---
title: "HMAC and verify"
description: "Key a digest with HMAC and compare an expected digest byte for byte. Hex ignores case and base64 never does"
canonical_url: "https://hashes.agntn.dev/guide/verify"
---
# HMAC and verify

> Key a digest with HMAC and compare an expected digest byte for byte. Hex ignores case and base64 never does

## HMAC is a key away

Pass `key` and the same `hash()` computes an HMAC. The result says so in `operation`.

```ts
import { create } from "@agntn/hashes";

const tag = create("sha256").hash("message", { key: "secret" });
tag.digest; // "8b5f48702995c1598c573db1e21866a9b825d4a794d169d7060a03605796360b"
tag.operation; // "hmac"
```

The key is text read as UTF-8, or a `Uint8Array`. The CLI and the tools can't pass bytes, so a binary key like a BIP32 chain code goes in as hex or base64 with `--key-encoding` or `keyEncoding`. HMAC exists for the algorithms built on a block, from SHA-2 and SHA-3 down to MD2 and HAVAL. HAVAL keeps its dials. `{ key, passes: 5, bits: 256 }` is HMAC-HAVAL-256 in 5 passes. Everything else, BLAKE3 and the Bitcoin compositions included, throws instead of pretending:

```ts
create("blake3").hash("message", { key: "secret" });
// HashError: [blake3] blake3 has no HMAC mode
```

BLAKE3 has its own keyed mode, and it isn't HMAC. Handing you a plain BLAKE3 digest because it ignored the key would be the worst possible answer, so you get an error.

The HMAC column on the [algorithm list](https://hashes.agntn.dev/algorithms) says which is which, read from `info().hmac`.

## Verify compares bytes

`digestMatches(result, expected)` decodes both digests in the result's encoding and compares the bytes. It goes through every byte even after the first difference, so the time it takes doesn't tell where they differ.

```ts
import { create, digestMatches } from "@agntn/hashes";

const hex = create("sha256").hash("abc");
digestMatches(hex, "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"); // true

const b64 = create("sha256").hash("abc", { encoding: "base64" });
digestMatches(b64, "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="); // true
digestMatches(b64, "ungwv48bz+pbqudexa4ii7adyaowf3qctbd/yfiafa0="); // false
```

Hex ignores case because `A` and `a` are the same nibble. Base64 doesn't, because they aren't the same byte. Lowercasing both sides before comparing is the classic shortcut, and for base64 it's a bug that says yes to a digest that isn't yours. Surrounding whitespace is trimmed. A string that isn't valid in the encoding is a plain `false`, not a crash.

`binary` can't be compared as text, and asking is an `InvalidOptionError`.

## From the terminal

```bash
hashes verify sha256 abc "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=" -e base64
# MATCH sha256 ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=

hashes verify sha256 abc "ungwv48bz+pbqudexa4ii7adyaowf3qctbd/yfiafa0=" -e base64
# MISMATCH sha256
#   expected ungwv48bz+pbqudexa4ii7adyaowf3qctbd/yfiafa0=
#   actual   ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=
```

A mismatch exits with 1, so `hashes verify ... && deploy` does what it reads like. `-e` is the encoding of the expected digest.

Got three guesses and one digest? Line them all up before it. `hashes verify md5 nope hello 5d41402abc4b2a76b9719d911017c592` marks `hello` as the match. It exits 1 only when every guess misses.

Here and in `hashes_verify` an expected digest that isn't valid in that encoding is an error, not a mismatch. A Keccak-256 hash copied from a block explorer comes with `0x` in front, and MISMATCH would say the data differs when only the argument is wrong. Drop the prefix and it matches.

A whole `$2y$` line pasted from `.htpasswd` hits the same wall. `expected` wants the digest alone. The error doesn't stop at no, though. It reads the string the way [identify](https://hashes.agntn.dev/guide/identify) does and hands back salt, cost and digest in hex:

```bash
hashes verify bcrypt ProbablyFine-2026 '$2y$04$X2kfXDUfLpgiqo1O5Bq6z.EhPme.LvywDHaK1QMd12JWp.XkUnIo6'
# Invalid option expected=60 characters: must be hex digit pairs, without a 0x prefix; its prefix reads as bcrypt: bcrypt, computable, salt 6789a16455a136b8a4b2add0ec3b3cd4, cost 4, expected 1a3468800371d3214970cdd239fdf82d8ac06665a92aaf
```

Copy those into the next call and it's a MATCH. Wrong once, right the second time, and no detour through identify. A model calling `hashes_verify` gets the same line.

A digest of the wrong length is still a MISMATCH, just not a bare one. Checked a HAVAL-256 digest against plain `haval`? That's 32 bytes against the 16 its defaults give:

```bash
hashes verify haval abc 8699f1e3384d05b2a84b032693e2b6f46df85a13a50d93808d6874bb8fb9e86c
# MISMATCH haval
#   expected 8699f1e3384d05b2a84b032693e2b6f46df85a13a50d93808d6874bb8fb9e86c
#   actual   9e40ed883fb63e985d299b40cda2b8f2
#   lengths differ: expected is 32 bytes, haval gave 16; --bits 256 gives 32, hashes identify names the other algorithms that do
```

No password was ever going to fix that. Add `--bits 256` and it matches. `hashes_verify` writes the same line with `parameters` in place of flags. A KDF gets the `keyLength` that fits. SHA-256 has no dial to turn, so it points you at identify.

`hashes hmac sha256 message secret` prints the tag alone. An algorithm without an HMAC mode is refused before anything is hashed.

## For a KDF

scrypt and PBKDF2 draw a random salt when you don't give one, so verifying without the salt could only ever say no. `hashes verify` and `hashes_verify` refuse to run without it, with an error that tells you which salt they want. In the library you pass the same `salt` and costs to `hash()` and then `digestMatches`. More on [Salted KDFs](https://hashes.agntn.dev/guide/kdf).

[HKDF](https://hashes.agntn.dev/algorithms/hkdf) and [EVP_BytesToKey](https://hashes.agntn.dev/algorithms/evp-bytestokey) are the exceptions. HKDF reads a missing salt as zeros and EVP_BytesToKey as no salt at all, so both verify without one.


## Sitemap

See the full [sitemap](https://hashes.agntn.dev/sitemap.md) for all pages.
