Air Force Logo
Thundercats Logo
All writing
cryptographysecuritykdfhkdf

Per-user encryption keys: a length-extension trap and the HKDF fix

2026-08-07·8 min read·Kirk Abbott

I run a multi-user SaaS where each user's account links to a third-party OAuth-protected API. The application stores per-user access and refresh tokens, encrypted at rest in the database. The basic shape is unremarkable: a master encryption key lives in an environment variable, and each user's tokens are encrypted with a per-user key derived from that master.

The interesting part is how I derived the per-user key. The first version was the obvious one. It was wrong. This is the story of finding out.

The first version

Each user has a numeric user_id. The master key is a 32-byte secret loaded from TOKEN_ENCRYPTION_KEY. To get a per-user encryption key:

def derive_per_user_key(master_key: bytes, user_id: int) -> bytes:
    return hashlib.sha256(master_key + str(user_id).encode()).digest()

This looks reasonable. It produces a 32-byte output. It's deterministic. Different users get different keys. The master key never leaves the server. What could go wrong?

Three things, in increasing order of how badly they bothered me when I figured them out.

Problem 1: SHA-256 leaks internal state

SHA-256 is a Merkle-Damgård hash. Its construction processes the input in 64-byte blocks, threading internal state forward at each step. The final output is the internal state at the end of processing, padded out into the digest format.

That has a specific consequence: if you know SHA256(master_key || user_id), you can compute SHA256(master_key || user_id || padding || extension) for any chosen extension, without knowing the master key. This is the length-extension attack. It's been textbook material since at least the early 2000s.

The attack works like this: the digest you observe is the same internal state SHA-256 would have if it were about to keep hashing. So you initialize a fresh SHA-256 to that state, feed it the right amount of length-padding to mimic the original message's end, then keep hashing whatever you want. The output is a valid SHA-256 of master_key || user_id || padding || extension.

For my specific construction, this means: an attacker who recovers user 1's derived key (through, say, a database leak of encrypted tokens plus a side-channel that revealed the per-user encryption key for that one user) can derive valid per-user keys for synthetic user IDs of the form 1 || padding || X for any X they choose.

Whether that's actually exploitable in my system depends on whether I can construct a request that's parsed against one of those synthetic IDs. Probably not directly. But “probably not directly” is exactly the kind of margin you don't want to live on, especially when the fix is two extra lines of code.

Note

SHA-3 (Keccak) and BLAKE2/3 don't have this problem because they're not Merkle-Damgård. If you really wanted to keep the hash-and-concat structure, you could swap to one of those. But the right fix is a different construction entirely, see below.

Problem 2: HMAC was the right primitive all along

The classic defense against length-extension is HMAC. HMAC's construction:

HMAC(key, message) = Hash(outer_pad XOR key || Hash(inner_pad XOR key || message))

The double-hashing structure means the final output is a hash of a hash, and an attacker who sees the output can't resume the inner hash because they don't know the key. Length-extension is impossible.

So the immediate “just patch it” fix would be:

def derive_per_user_key(master_key: bytes, user_id: int) -> bytes:
    return hmac.new(master_key, str(user_id).encode(), hashlib.sha256).digest()

That's a real improvement. It closes the length-extension attack. It's also still wrong, in a more subtle way.

Problem 3: a hash function is not a KDF

A key derivation function is a primitive specifically designed to take input keying material and produce output keys with two important properties beyond just “different inputs give different outputs”:

  1. Domain separation: derived keys for different purposes from the same input are computationally independent. If I use the master key to derive an encryption key and also to derive a session-signing key, the two outputs should be uncorrelated even if they share construction.

  2. Context binding: the derivation should accept a structured “info” parameter that defines what this key is for, who it's for, and what version of the scheme produced it. This makes it possible to evolve key derivation later without confusing old and new keys.

A bare HMAC call gives you neither. There's no place for a salt, no place for an info string, no version label. If you ever decide to derive a second per-user key for a different purpose using the same master, you're just inviting yourself to make the same mistake again.

The right tool is HKDF, defined in RFC 5869. HKDF is essentially HMAC with the right ergonomics: a salt, a master key (called the input keying material, IKM), and an info string. It runs in two phases, extract and expand, and produces output keying material of any requested length.

The fix

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 
# Module-level constants document the derivation context. Changing either
# of these later silently re-keys all derived material, so they're versioned.
SALT = b"my-app-token-encryption-v1"
INFO_PREFIX = b"per-user-token-key:"
 
def derive_per_user_key(master_key: bytes, user_id: int) -> bytes:
    info = INFO_PREFIX + str(user_id).encode()
    hkdf = HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=SALT,
        info=info,
    )
    return hkdf.derive(master_key)

What changed:

  • Length-extension is gone. HKDF's extract phase is HMAC, not bare SHA-256.
  • The salt provides domain separation. If I derive other keys from the same master in the future, I use a different salt and the outputs are uncorrelated.
  • The info string carries context. per-user-token-key:42 derives a key that is specifically the token-encryption key for user 42. If I later need a different per-user key (say, a per-user MAC key for tamper detection), the info string for that purpose is different, and the keys are independent.
  • The version suffix on SALT (-v1) gives me a clean upgrade path. If I ever change the construction, I bump to -v2 and run a migration.
Tip

The salt and info strings are not secrets. They're labels. You can publish them in your code or your documentation without weakening the scheme. What they do is make sure that a master key compromised across one purpose doesn't compromise other purposes, and that two derived keys with the same input but different purposes are computationally distinct.

What this protects against, and what it doesn't

The HKDF version protects against:

  • Length-extension attacks on the per-user derivation
  • Cross-purpose key reuse (if you use the same master for multiple derivations with different salt/info, the outputs are independent)
  • Future-you accidentally repeating the original mistake when adding a second per-user key

It does not protect against:

  • The master key itself being compromised. If TOKEN_ENCRYPTION_KEY leaks, every derived key is recoverable. HKDF is deterministic given inputs, by design; that's how the same per-user key gets reproduced on every server restart.
  • Database compromise where the encrypted tokens and the running process memory containing the master are both exposed. Encryption-at-rest is a real defense layer, but it's one layer.
  • Side-channel attacks on the encryption operation itself.
  • An attacker who can call your encryption function as an oracle.

The point of fixing the KDF isn't to make the system unbreakable. The point is to remove a specific, unnecessary, well-understood class of weakness from a layer that's easy to get right with one library call.

The migration

The change broke nothing visible. New tokens encrypt and decrypt against the new HKDF-derived keys. Old tokens were already in the database, encrypted with old keys.

I had two options for old tokens: re-encrypt them in place (read with old derivation, write with new), or invalidate them and force users to re-authenticate. I chose to invalidate. The user-facing cost was a one-time “reconnect your account” click for each user; the engineering cost was a few hours.

Re-encrypt-in-place would have been faster for users but would have required keeping the old derivation function around indefinitely as a decrypt-only path. That kind of vestigial code is exactly the maintenance trap that turns a clean security upgrade into permanent technical debt. For a system with hundreds of users instead of millions, the “just re-auth” path is the right one.

Takeaway

If you find yourself writing Hash(secret + something) to derive a key, stop. The thing you actually want is HKDF, or any of its standardized cousins (HKDF-Extract-then-Expand is the most common; some libraries also expose derive_key interfaces backed by it). Reach for the standard primitive. The library does it right; you almost certainly will not.

This particular fix took about two hours, including the migration, the tests, and writing the explanatory comment in the code. The vulnerability had been in production for several months. It hadn't been exploited as far as I could tell, but the time-to-fix from “huh, that's wrong” to “deployed” should have been measured in days from when I first wrote it, not months.

Half of security engineering is recognizing when the construction you reached for is the wrong one. The other half is closing the gap quickly when you do.