Building 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 appears:
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 very 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 the thing every brain does first.
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 we trust 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, it looks like this:
H0 = IV
H1 = compress(H0, block1)
H2 = compress(H1, block2)
...
output = HnOne detail matters more than everything else:
The output hash is the final internal state.
There is no extra sealing step at the end.
Which means:
if you know Hash(M), you know the state after processing M.
And that detail matters far more than intuition suggests.
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.
Because they can do this:
tag' = SHA256_continue(
state = tag,
data = padding(K || M) || extra
)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.
No key.
No guessing.
No cryptanalysis.
The attacker just extended an authenticated message.
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 the structure leaks, let’s hide it.
What about this?
tag = SHA256(K || SHA256(K || M))Now the internal state of the first hash is buried inside another hash.
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 fixes the problem
This is exactly the pattern we saw in Part 2.
We’re patching again.
And we already know where patching leads.
So let’s stop and reset.
Define the problem properly (again)
From everything we learned so far, a real hash-based MAC must guarantee:
Only someone with the key can compute a valid tag
Only someone with the key can verify a valid tag
The message cannot be extended or truncated
The internal hash state cannot be reused
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 what if we don’t do that?
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 shape becomes:
inner = SHA256( (K ⊕ ipad) || M )
tag = SHA256( (K ⊕ opad) || inner )Don’t focus on the constants yet.
Focus on the structure:
the message is fully absorbed before finalization
the attacker never gets a state they can continue
the key controls both boundaries
length extension becomes impossible
This feels different.
Because this time, we’re not guessing.
We’re designing.
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) = byte 0x36 repeated to block size
opad (outer padding) = byte 0x5c repeated to block size
They ensure that:
the inner hash and outer hash live in different domains
no internal state can be reused across phases
Hash(K ⊕ ipad || M) can never collide structurally with Hash(K ⊕ opad || something_else)
In other words:
ipad and opad prevent the inner hash from being mistaken for the outer hash.
They are not there for randomness.
They are there to make structure explicit and 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?
No — the state is never exposed in a usable form.
Can they fake a tag without the key?
No — both passes depend on secret key material.
Does variable message length matter?
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 important part is that:
it exists because constraints exist
it looks complex because the problem is subtle
it survived decades of cryptanalysis because it was designed, not guessed
Python implementation (SHA-256 + HMAC)
As before, we’ll use a library for the primitive and write the logic ourselves.
We are not trying to reimplement SHA-256 bit by bit.
We are showing the structure clearly.
import hashlib
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
if len(key) > block_size:
key = sha256(key)
if len(key) < block_size:
key = key + b"\x00" * (block_size - len(key))
ipad = bytes([0x36] * block_size)
opad = bytes([0x5c] * block_size)
inner = sha256(bytes(k ^ i for k, i in zip(key, ipad)) + message)
tag = sha256(bytes(k ^ o for k, o in zip(key, opad)) + inner)
return tagThere is no magic here.
Every line corresponds to a design decision we just derived.
And if you compare the output with Python’s built-in hmac module, it will match.
Testing the implementation
A MAC is useless if you can’t trust it.
So we verify our implementation against Python’s standard library:
import hmac
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()If this assertion passes, your implementation is correct.
No hand-waving.
No “it seems to work”.
Just a hard yes or no.
Final symmetry
Let’s 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 it’s just intuition lying to you again.


