DEV Community

Cover image for Building Your Own MAC — Part 3: reinventing HMAC from SHA-256
Dmytro Huz
Dmytro Huz

Posted on • Originally published at dmytrohuz.com

Building Your Own MAC — Part 3: reinventing HMAC from SHA-256

In the previous article, we did something slightly ridiculous.

We took a block cipher — a tool designed to transform one block into one block — and forced it to behave like something else.

We wanted authentication.
We needed a fixed-size tag.
We had arbitrary-length messages.

So we built a “message → block” machine out of a “block → block” primitive.

It worked.

We reinvented CMAC.

And then an uncomfortable thought appeared:

Why did we do all of that?

A strange déjà vu

Look again at the final construction from Part 2.

It has this shape:
• arbitrary-length input
• processed block by block
• a small internal state
• a fixed-size output
• no way to reverse it
• sensitive to every bit of input

That shape should feel familiar.

Because that is exactly the shape of a hash function.

So the obvious question is:

If hash functions already compress messages into fixed-size values,
why didn’t we start there?

Fix attempt #1 — “Just hash with a secret”

Let’s do what intuition suggests immediately.

We want a tag.
We have a hash function.
We also have a secret key.

So we try:

tag = SHA256(K || M)

Or maybe:

tag = SHA256(M || K)

It feels clean.
It feels simple.
It feels much simpler than CMAC.

No AES.
No modes.
No subkeys.
No final-block gymnastics.

Before trusting it, we do what this series is about.

We break it.

A reminder: how SHA-256 actually works

SHA-256 is not a black box.

Internally, it is an iterative compression machine.

Here, compression does not mean “making data smaller” in the everyday sense.

It means something more precise:

a function that takes
a fixed-size internal state
and a fixed-size input block,
and produces a new fixed-size state.

Nothing is expanded.
Nothing is reversible.
Information is folded into state.

Conceptually, SHA-256 looks like this:

H0 = IV
H1 = compress(H0, block1)
H2 = compress(H1, block2)
...
output = Hn
Enter fullscreen mode Exit fullscreen mode

Each message block updates the state.
The final hash is simply the last state.

And that detail — that the output is the internal state — is exactly where intuition starts to lie to us.

Break — length extension

Assume the system uses:

tag = SHA256(K || M)

The attacker sees:
• the message M
• the tag SHA256(K || M)

They do not know K.

But they do know:
• the hash algorithm
• the block size
• the padding rules

And that is enough.

They can do this:

tag' = SHA256_continue(
          state = tag,
          data  = padding(K || M) || extra
       )
Enter fullscreen mode Exit fullscreen mode

Result:

tag' = SHA256(K || M || padding || extra)

The attacker reused the final internal hash state and simply continued the hash computation, producing a valid tag for a longer message without knowing the key.

This is a length extension attack.

And it completely breaks this construction.

Important lesson #1

This is not a weakness of SHA-256.

SHA-256 did exactly what it was designed to do.

The failure is conceptual:

You treated a structured machine as if it were a black box.
Hash functions expose their internal chaining state by design.

If your MAC construction allows an attacker to reuse that state, it is broken.

Fix attempt #2 — “Fine. Let’s hash twice.”

Okay.

If structure leaks, let’s hide it.

tag = SHA256(K || SHA256(K || M))

This feels safer.

But pause for a moment and look at what we’re doing.

We are:
• stacking primitives blindly
• hoping structure disappears
• having no clear argument why this works

This is exactly the pattern we saw in Part 2.

We are patching again.

And patching never survives contact with attackers.

So let’s reset properly.

Define the problem correctly (again)

From everything we learned so far, a real hash-based MAC must guarantee:
1. Only someone with the key can compute a valid tag
2. Only someone with the key can verify a valid tag
3. The message cannot be extended or truncated
4. The internal hash state cannot be reused
5. Variable-length messages must be safe by design

So the real question is not:

“How do we mix a key into a hash?”
The real question is:

How do we prevent the attacker from continuing the hash computation?

The key insight — control the boundaries

The mistake so far was mixing everything into one stream:
• key
• message
• finalization

That gave the attacker something extendable.

So we separate roles.

What if:
• the key is mixed before the message
• the message is fully compressed
• the key is mixed again after

So the attacker never sees a reusable internal state.

The structure becomes:

inner = SHA256( (K ⊕ ipad) || M )
tag   = SHA256( (K ⊕ opad) || inner )
Enter fullscreen mode Exit fullscreen mode

What are ipad and opad?

At first glance, ipad and opad look like magic constants.

They are not.

They serve one very specific purpose: domain separation.
• ipad (inner padding) is the byte 0x36 repeated to the hash block size
• opad (outer padding) is the byte 0x5c repeated to the hash block size

They ensure that:
• the inner hash and the outer hash live in different domains
• no internal hash state can be reused across phases
• the structure of the computation is explicit and unambiguous

In simple terms:

ipad and opad make it cryptographically impossible to confuse
“hashing a message” with “finalizing a tag”.

They are not there for randomness.

They are there to make structure unforgeable.

Why this construction survives

Let’s stress it the same way we stressed everything else.
• Can the attacker extend the message?
No — the inner hash is finalized before the outer hash begins.

• Can they reuse an internal state?
Enter fullscreen mode Exit fullscreen mode

No — the state is never exposed in a usable form.

• Can they fake a tag without the key?
Enter fullscreen mode Exit fullscreen mode

No — both passes depend on secret key material.

• Does variable message length matter?
Enter fullscreen mode Exit fullscreen mode

No — the hash function already handles it safely.

This construction doesn’t feel clever.

It feels inevitable.

Exactly like CMAC did once all constraints were visible.

Name reveal: HMAC

At this point, we can finally say the name.

The construction we just derived is called:

HMAC — Hash-based Message Authentication Code

And just like with CMAC, the name is the least interesting part.

The design came first.
The name came later.

That’s how real primitives are born.

Python implementation (SHA-256 + HMAC)

As before, we use a library for the primitive and write the logic ourselves.

We are not re-implementing SHA-256 bit by bit.
We are making the structure explicit.

import hashlib
import hmac

def sha256(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()

def hmac_sha256(key: bytes, message: bytes) -> bytes:
    block_size = 64  # SHA-256 block size (bytes)

    # Step 1: normalize the key
    if len(key) > block_size:
        key = sha256(key)
    if len(key) < block_size:
        key = key + b"\x00" * (block_size - len(key))

    # Step 2: define inner and outer pads
    ipad = bytes([0x36] * block_size)
    opad = bytes([0x5c] * block_size)

    # Step 3: inner hash
    inner = sha256(bytes(k ^ i for k, i in zip(key, ipad)) + message)

    # Step 4: outer hash (final tag)
    tag = sha256(bytes(k ^ o for k, o in zip(key, opad)) + inner)

    return tag

Enter fullscreen mode Exit fullscreen mode

Testing the implementation

A MAC is useless if you can’t verify it.

So we test our implementation against Python’s standard library:

def test_hmac():
    key = b"super-secret-key"
    message = b"hello world"

    my_tag = hmac_sha256(key, message)
    std_tag = hmac.new(key, message, hashlib.sha256).digest()

    assert my_tag == std_tag
    print("HMAC implementation verified.")

test_hmac()
Enter fullscreen mode Exit fullscreen mode

If this assertion passes, the implementation is correct.

No hand-waving.
No “it seems to work”.

Just a hard yes or no.

Final symmetry

Zoom out one last time.

In this series, we built two MACs from scratch:
• CMAC — built from a block cipher
• HMAC — built from a hash function

Different primitives.
Same constraints.

And in both cases, the path was identical:
• intuition failed
• naive fixes broke
• constraints emerged
• structure followed
• names came last

Once you see the constraints, the designs stop looking arbitrary.

They look inevitable.

And that was the real goal of this series.

Not to teach you how to use MACs.

But to teach you how to recognize when a construction makes sense —
and when intuition is lying to you again.

Top comments (0)