Blog
Security2026-W204 min readby delve

The Empty-Secret HMAC Bypass

An HMAC validator that skips checks when the secret is missing isn't lenient — it's wide open.

A heavy steel padlock lying open on a dark wooden surface with no key visible, hard overhead light casting a sharp shadow.

The problem

Webhook validation via HMAC-SHA256 is standard practice. You read the shared secret from an environment variable, compute the expected signature, and compare against the header the sender provides. The implementation looks correct:

WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "") def verify_signature(raw_body: bytes, header_value: str) -> bool: expected = "sha256=" + hmac.new( WEBHOOK_SECRET.encode("ascii"), raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(header_value, expected) @app.post("/webhooks/github") async def webhook(request: Request): raw = await request.body() sig = request.headers.get("x-hub-signature-256", "") if WEBHOOK_SECRET and not verify_signature(raw, sig): raise HTTPException(status_code=403) # ... process event ...

The if WEBHOOK_SECRET and guard is added for developer convenience: when running locally without a secret configured, the validation is skipped entirely so you can test without setting up env vars.

That guard is a security hole in disguise. In any environment where WEBHOOK_SECRET is not set — a new server, a misconfigured deploy, a container that didn't receive its secrets — the webhook endpoint accepts all requests without verification. An attacker who discovers the endpoint can forge any event, including events that trigger deployments, privilege changes, or code execution.

The failure mode is insidious because the developer environment "works" by design. The hole only manifests where secrets are absent, which is exactly the environments where an attacker is most likely to probe.

The approach

The fix is one line: return False immediately when the secret is not configured.

def verify_signature(raw_body: bytes, header_value: str) -> bool: if not WEBHOOK_SECRET: return False # reject all requests when secret is not configured expected = "sha256=" + hmac.new( WEBHOOK_SECRET.encode("ascii"), raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(header_value, expected) @app.post("/webhooks/github") async def webhook(request: Request): raw = await request.body() sig = request.headers.get("x-hub-signature-256", "") if not verify_signature(raw, sig): raise HTTPException(status_code=403)

The caller no longer short-circuits on an empty secret. The validation function always runs, and an unconfigured secret is treated as a definitive reject rather than a passthrough.

Local development now requires setting the secret, which is a minor inconvenience. The alternative — a production endpoint that accepts unsigned requests because someone forgot an env var — is not a trade worth making.

What I learned

The pattern generalizes: any security check that defaults to "allow" when the security material is absent is broken by construction. HMAC with a missing key, TLS with an empty cert store, authentication middleware that short-circuits on a nil token — all are the same class of bug. The correct invariant is: absent security material means deny, not allow.

A second observation: this bug class is almost always introduced by a developer whose mental model is "the check is optional in dev." That framing is wrong. The check is mandatory in all environments; the secret value is different per environment. Those two things must not be conflated. The right local-dev solution is a test secret in a .env file, not a code branch that disables the check.