Cryptographic Implementation Security Questions
Security of cryptography as actually implemented in code, where a correct algorithm still fails through misuse, side-channel leakage, or faulty error handling. Covers cryptographic API misuse patterns (nonce and IV reuse, ECB mode, hardcoded secrets, unauthenticated ciphertext, algorithm confusion), timing and cache side-channels, constant-time coding techniques (masking, blinding, formal constant-time verification), physical side-channel and fault-injection attacks and their countermeasures (power analysis, electromagnetic leakage, voltage and laser glitching), padding-oracle and other implementation-level cryptanalytic attacks (Bleichenbacher, CBC padding oracles, nonce-reuse key recovery), cryptographic failure-mode handling, and implementation auditing (code review checklists, static and dynamic misuse detectors, fuzzing). Assumes the algorithm, key, and RNG have already been selected: distinct from choosing and provisioning primitives, key derivation, and random number generation (applied cryptography and key management) and from encryption-at-rest and in-transit architecture (data protection and encryption).
What are common misuses of cryptographic libraries by application developers? Propose a company-wide strategy to reduce these misuses, including safer API wrappers, code review checklists, education, and automation.
Sample Answer
Direct answer
The recurring misuses application developers make with cryptographic libraries are: using unauthenticated or pattern-preserving modes like ECB (electronic codebook, encrypts identical blocks to identical ciphertext), reusing a nonce (a number used once) or initialization vector (IV) where uniqueness is required, silently ignoring cryptographic error returns, and reaching for low-level primitives (raw block-cipher calls, raw RSA (Rivest-Shamir-Adleman) operations) instead of a vetted high-level construction. A company-wide strategy to reduce these needs to make the safe path the easy path: secure-defaults API wrappers, automated detection in CI/CD (continuous integration and continuous delivery, the pipeline that builds, tests, and ships code), a review checklist, and targeted education, in that priority order, since automation and defaults scale further than developer memory.
Structured elaboration
Common misuses:
- ECB mode: encrypts identical plaintext blocks to identical ciphertext blocks, leaking the structure of the plaintext even without breaking the key.
- Nonce or IV reuse: under a stream-cipher-style mode (CTR, or GCM's internal counter construction), reusing a nonce with the same key lets an attacker XOR (exclusive-or) two ciphertexts together and recover the XOR of the two plaintexts directly.
- Failing to check errors: ignoring a decryption or verification failure return value and proceeding to use the (possibly forged or corrupted) result anyway.
- Using low-level primitives incorrectly: calling raw block-cipher encrypt/decrypt without authentication, implementing custom padding, or using textbook RSA without proper padding, each reintroducing a class of well-studied vulnerability that a vetted high-level API already closed off.
Company-wide strategy:
- Safer API wrappers (secure defaults): provide one internal, misuse-resistant encrypt/decrypt entry point that always uses authenticated encryption with associated data (AEAD), generates its own nonce, and returns a clear error type on any failure rather than a value that could be mistaken for success.
- Automation in CI/CD: static analysis rules that flag ECB mode, hardcoded key material, and weak pseudorandom number generators used for security purposes, run on every pull request as part of a broader static and dynamic misuse-detection pipeline.
- Code review checklist: a short, concrete checklist (not a vague "review for security") that reviewers actually use, covering the same anti-patterns the automation checks for, as a human backstop for what a linter cannot catch.
- Education: targeted, example-driven training (not a generic "crypto 101" deck) tied to the specific anti-patterns your own incident history or static-analysis findings surface, delivered close in time to when a developer actually needs it (for example, as part of onboarding to a service that touches cryptographic code).
- Deprecate and ban direct access: once the wrapper is available, add a CI rule that fails the build on any new direct import of the underlying low-level library, so new code cannot bypass the wrapper even during a long migration.
Worked example
The following demonstrates two of these misuses concretely and their fixes, executed against the real AES (Advanced Encryption Standard) implementation in a production cryptography library, not asserted in the abstract.
import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
KEY = hashlib.sha256(b"pinned-demo-key-seed-v2").digest()
def ecb_encrypt(plaintext_blocks: bytes) -> bytes:
encryptor = Cipher(algorithms.AES(KEY), modes.ECB()).encryptor()
return encryptor.update(plaintext_blocks) + encryptor.finalize()
def aead_encrypt(plaintext: bytes, nonce: bytes) -> bytes:
return AESGCM(KEY).encrypt(nonce, plaintext, associated_data=None)
if __name__ == "__main__":
# --- misuse 1: ECB mode leaks that two 16-byte blocks are equal, even
# though the attacker never learns the key or the plaintext values ---
block = b"REPEAT-PATTERN!!" # 16 bytes, deliberately identical across repeats
plaintext = block * 4 # 4 identical plaintext blocks, e.g. a bitmap's blank rows
ct_ecb = ecb_encrypt(plaintext)
ciphertext_blocks = [ct_ecb[i:i + 16] for i in range(0, len(ct_ecb), 16)]
print("ECB ciphertext blocks for 4 identical plaintext blocks:")
for i, b in enumerate(ciphertext_blocks):
print(f" block {i}: {b.hex()}")
print(f"all 4 ciphertext blocks identical: {len(set(ciphertext_blocks)) == 1} "
f"(structure of the plaintext leaks with zero key knowledge)")
# --- fix: AEAD (AES-GCM). Same repeating plaintext, unique nonce, and the
# ciphertext no longer exposes block-level repetition ---
nonce = hashlib.sha256(b"pinned-demo-nonce-seed-v2").digest()[:12]
ct_gcm = aead_encrypt(plaintext, nonce)
gcm_body = ct_gcm[:-16] # strip the 16-byte auth tag to compare ciphertext body only
gcm_blocks = [gcm_body[i:i + 16] for i in range(0, len(gcm_body), 16)]
print(f"\nAES-GCM ciphertext blocks for the SAME repeating plaintext:")
for i, b in enumerate(gcm_blocks):
print(f" block {i}: {b.hex()}")
print(f"all 4 ciphertext blocks identical: {len(set(gcm_blocks)) == 1} (must be False)")
# --- misuse 2: nonce reuse under a stream-like mode (CTR) leaks the XOR
# of two plaintexts the moment the SAME nonce is reused ---
def ctr_encrypt(pt: bytes, nonce: bytes) -> bytes:
encryptor = Cipher(algorithms.AES(KEY), modes.CTR(nonce + b"\x00" * (16 - len(nonce)))).encryptor()
return encryptor.update(pt) + encryptor.finalize()
reused_nonce = hashlib.sha256(b"pinned-demo-nonce-seed-v2").digest()[:12]
msg_a = b"transfer $10 to bob!!!"
msg_b = b"transfer $99999 to eve"
assert len(msg_a) == len(msg_b)
ct_a = ctr_encrypt(msg_a, reused_nonce)
ct_b = ctr_encrypt(msg_b, reused_nonce) # same nonce reused: the bug
xor_of_ciphertexts = bytes(a ^ b for a, b in zip(ct_a, ct_b))
xor_of_plaintexts = bytes(a ^ b for a, b in zip(msg_a, msg_b))
print(f"\nnonce reuse under CTR: XOR(ct_a, ct_b) == XOR(pt_a, pt_b): "
f"{xor_of_ciphertexts == xor_of_plaintexts}")
print("(an attacker who knows or guesses one plaintext recovers the other "
"directly from that XOR, with no key material needed)")
Encrypting four identical 16-byte plaintext blocks (for example, four blank rows of a bitmap):
ECB ciphertext blocks for 4 identical plaintext blocks:
block 0: d7e3d212a2dc0b5cc4d3de6846be077d
block 1: d7e3d212a2dc0b5cc4d3de6846be077d
block 2: d7e3d212a2dc0b5cc4d3de6846be077d
block 3: d7e3d212a2dc0b5cc4d3de6846be077d
all 4 ciphertext blocks identical: True (structure of the plaintext leaks with zero key knowledge)
AES-GCM ciphertext blocks for the SAME repeating plaintext:
block 0: 544f53cb313ae00cf273aa7c5c20ef99
block 1: 05a2493f93909dcb04083f6b8781d9b0
block 2: 7b0700634e38a35c2fd4bf9de50fdee8
block 3: 5650b8a9e43732616eb5f999e4a39f4e
all 4 ciphertext blocks identical: False (must be False)
And nonce reuse under counter (CTR) mode with the same key and nonce for two different messages of equal length:
nonce reuse under CTR: XOR(ct_a, ct_b) == XOR(pt_a, pt_b): True
(an attacker who knows or guesses one plaintext recovers the other directly from that XOR, with no key material needed)
Trade-offs and pitfalls
A misuse-resistant wrapper only helps if direct access to the underlying library is actually removed from new code, not just discouraged; without an enforced CI ban, teams under deadline pressure route around the "recommended" wrapper the first time it doesn't do something they need. Static analysis for these patterns has a real false-positive cost (a rule that flags every Cipher.getInstance call rather than only ones with a hardcoded key gets disabled by frustrated developers within weeks); tune detection rules carefully rather than shipping a broad, noisy first pass. Education alone, without automation, does not scale: a training deck delivered once during onboarding is largely forgotten by the time a developer actually writes vulnerable code six months later; pair it with just-in-time automated feedback in the pull request itself.
Given the following pseudocode for decrypting AES-CBC messages, identify the vulnerability that creates a padding oracle and rewrite the control flow to avoid leaking padding errors. Explain why your changes are safe and suggest an AEAD-based alternative.
pseudocode snippet: def decrypt(ciphertext, key, iv): plaintext = aes_cbc_decrypt(ciphertext, key, iv) try: unpadded = pkcs7_unpad(plaintext) except PaddingError: return 'padding error' if not verify_hmac(unpadded): return 'mac error' return unpadded
Sample Answer
Direct answer
The pseudocode decrypts first, checks padding second, and checks the message authentication code (MAC, a keyed checksum that proves both integrity and authenticity of a message) third, and it returns a different string for each of the two failure reasons. That ordering and that distinguishable-error behavior together are the padding oracle: an attacker who can submit arbitrary ciphertexts and observe which error string comes back can use the 'padding error' versus 'mac error' distinction as a one-bit oracle, and Vaudenay's classical CBC (cipher block chaining) padding-oracle attack turns roughly 256 oracle queries per byte into full plaintext recovery, no key needed. The fix is to verify the MAC over the ciphertext FIRST, in constant time, before the decryptor or the unpadder ever runs, and to report every failure the same way.
Structured elaboration
Why "decrypt, unpad, then MAC" is broken
- An attacker does not need the padding check to leak via timing to build an oracle; a distinguishable RETURN VALUE ('padding error' vs 'mac error') is a much stronger, noise-free oracle than timing ever is.
- Because the MAC is checked last, an attacker can submit a ciphertext with an intentionally corrupted final block, learn from the response whether the resulting plaintext happened to have valid PKCS7 (Public-Key Cryptography Standards #7) padding, and repeat this against every possible last byte value. A valid-padding response reveals the actual plaintext byte with simple arithmetic, and the attack proceeds byte by byte, block by block.
- This is a special case of the general "Cryptographic Doom Principle" (a widely cited framing from security engineer Moxie Marlinspike): if you ever process untrusted ciphertext before verifying its authenticity, whatever that processing does, however subtly, becomes attacker-observable behavior.
The fix: verify-then-decrypt
- Compute the expected MAC over the raw ciphertext and compare it to the supplied tag using a constant-time comparison (equal work regardless of where or whether the bytes differ, so equality itself carries no timing signal).
- Only if that comparison succeeds do you decrypt and unpad at all. An attacker who cannot forge a valid tag can never get a crafted ciphertext far enough to exercise the padding check, so the padding oracle becomes unreachable from outside the system.
- Collapse every failure path, bad tag or (only theoretically reachable, given a valid tag) bad padding underneath, into the exact same return value. There is no legitimate reason for a caller to be told which one happened.
Complexity and edge cases
- Cost: one MAC computation over the ciphertext (linear in message length) whether or not the message turns out to be valid, plus the decryption cost only on the success path; this is strictly cheaper than the original code on the failure path, since it skips decryption and unpadding entirely.
- Edge cases the fix has to cover: an empty ciphertext, a ciphertext whose length is not a multiple of the block size (reject before touching the decryptor at all), and a tag of the wrong length (compare lengths first, but do so without early-exiting on a byte-by-byte basis for the tag comparison itself, since Python's
hmac.compare_digestand equivalent library functions are specifically designed to make this safe).
Worked example
import hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding as sympadding
KEY = bytes(range(32)) # AES-256 key, fixed for reproducibility (demo only)
MAC_KEY = bytes(range(32, 64)) # separate HMAC key -- never reuse the encryption key
IV = bytes(range(16))
def aes_cbc_decrypt(ciphertext, key, iv):
d = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
return d.update(ciphertext) + d.finalize()
def aes_cbc_encrypt(padded, key, iv):
e = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
return e.update(padded) + e.finalize()
def pkcs7_unpad(padded):
u = sympadding.PKCS7(128).unpadder()
# .finalize() performs the actual padding check -- omitting it silently accepts
# any trailing bytes and would defeat this whole example.
return u.update(padded) + u.finalize()
def pkcs7_pad(data):
p = sympadding.PKCS7(128).padder()
return p.update(data) + p.finalize()
def hmac_tag(data, mac_key):
return hmac.new(mac_key, data, digestmod='sha256').digest()
def vulnerable_decrypt(ciphertext, tag, key, iv, mac_key):
"""As given in the question: decrypt, unpad, THEN check the MAC, with a distinct
return string for each failure reason."""
plaintext_padded = aes_cbc_decrypt(ciphertext, key, iv)
try:
unpadded = pkcs7_unpad(plaintext_padded)
except ValueError:
return 'padding error'
expected = hmac_tag(ciphertext, mac_key)
if not hmac.compare_digest(expected, tag):
return 'mac error'
return unpadded
def fixed_decrypt(ciphertext, tag, key, iv, mac_key):
"""Verify-then-decrypt: the MAC is checked over the ciphertext, in constant time,
before the decryptor or unpadder ever runs. Any failure -- bad tag or (only
theoretically reachable) bad padding under a valid tag -- returns the same None."""
expected = hmac_tag(ciphertext, mac_key)
if not hmac.compare_digest(expected, tag):
return None
plaintext_padded = aes_cbc_decrypt(ciphertext, key, iv)
try:
return pkcs7_unpad(plaintext_padded)
except ValueError:
return None
def run_case(label, ciphertext, tag):
v = vulnerable_decrypt(ciphertext, tag, KEY, IV, MAC_KEY)
f = fixed_decrypt(ciphertext, tag, KEY, IV, MAC_KEY)
print(f"{label}:")
print(f" vulnerable_decrypt -> {v!r}")
print(f" fixed_decrypt -> {f!r}")
plaintext = b'transfer:5000:acct-8842-checking'
padded = pkcs7_pad(plaintext)
ct = aes_cbc_encrypt(padded, KEY, IV)
good_tag = hmac_tag(ct, MAC_KEY)
run_case('A. valid ciphertext + valid tag', ct, good_tag)
early_flip = bytearray(ct)
early_flip[0] ^= 0x01 # perturbs only the first plaintext block, padding intact
run_case('B. bit-flip in an early block (padding untouched, MAC invalid)',
bytes(early_flip), good_tag)
bad_padded = bytearray(padded)
bad_padded[-1] = 0xFF # not a legal PKCS7 pad byte
bad_ct = aes_cbc_encrypt(bytes(bad_padded), KEY, IV)
bad_tag = hmac_tag(bad_ct, MAC_KEY)
run_case('C. valid tag, malformed padding underneath', bad_ct, bad_tag)
Output:
A. valid ciphertext + valid tag:
vulnerable_decrypt -> b'transfer:5000:acct-8842-checking'
fixed_decrypt -> b'transfer:5000:acct-8842-checking'
B. bit-flip in an early block (padding untouched, MAC invalid):
vulnerable_decrypt -> 'mac error'
fixed_decrypt -> None
C. valid tag, malformed padding underneath:
vulnerable_decrypt -> 'padding error'
fixed_decrypt -> None
Cases B and C are the point: the vulnerable version tells the attacker exactly which check failed ('mac error' versus 'padding error'), while the fixed version collapses both into the identical None, giving an attacker zero bits of oracle signal to work with.
Trade-offs and pitfalls
Verify-then-decrypt with a separate MAC still leaves several ways to get it subtly wrong: MAC-ing only the ciphertext and not the IV lets an attacker manipulate the IV to flip bits in the first plaintext block without detection, and using two independent keys (as this example does) is mandatory, reusing the encryption key as the MAC key breaks the security proof entirely. This is exactly why an AEAD (Authenticated Encryption with Associated Data) mode like AES-GCM (Galois/Counter Mode) or ChaCha20-Poly1305 is the preferred alternative for new code: it binds encryption and authentication into a single primitive with a single verified construction, so there is no manual ordering decision left to get wrong, no second key to manage, and no separate padding scheme to attack in the first place, since these modes are stream-cipher-based and do not pad at all.
Design a secure password-reset token mechanism for a web application. Include token generation (entropy length), storage (hashing vs plaintext), expiry and single-use enforcement, rate-limiting, and defenses against token prediction, reuse, and abuse. Also describe how you would log and monitor reset flows for abuse without leaking sensitive information.
Sample Answer
Direct answer
A secure password-reset token needs four properties working together: enough entropy that guessing succeeds only through infeasible brute force, storage as a hash rather than plaintext so a database leak does not hand out live tokens, a short expiry plus single-use enforcement so a leaked or intercepted token has a small blast radius, and rate-limiting plus abuse monitoring on both the request and redemption endpoints so brute force and account enumeration get caught before they succeed.
Structured elaboration
- Token generation (entropy): use a cryptographically secure pseudorandom number generator (CSPRNG, for example Python's
secretsmodule or the operating system's/dev/urandom), never a general-purpose pseudorandom generator like a Mersenne Twister seeded from a timestamp. Encode at least 128 bits of raw entropy; 256 bits is a comfortable modern default, URL-safe base64 encoded so it drops cleanly into an email link. - Storage (hashing vs plaintext): store only a hash of the token plus metadata (user id, expiry, used flag); never persist or log the raw token anywhere durable. A fast hash like SHA-256 is appropriate here, unlike a password, the token already carries 256 bits of entropy on its own, so a slow key derivation function (KDF) adds cost without adding security.
- Comparison: compare the incoming token's hash to the stored hash using a constant-time comparison function (for example
hmac.compare_digest), not a plain==, to avoid a byte-by-byte timing oracle on the comparison itself. - Expiry and single-use: use a short time-to-live (commonly 15 to 60 minutes), and mark the token used atomically at first successful redemption, inside the same transaction or lock that grants the reset, so a race between two concurrent redemption requests cannot both succeed.
- Rate-limiting and abuse defenses: rate-limit the "request a reset" endpoint per account and per source IP (prevents mass token issuance and email-bombing); rate-limit or lock out repeated failed redemption attempts; and return an identical response whether or not the submitted email exists, to prevent account enumeration.
- Logging and monitoring without leaking secrets: log the event itself (reset requested or redeemed, user id, timestamp, source IP) but never the raw token or its hash in a general-purpose log; alert on anomalies such as many reset requests for one account in a short window, many failed redemption attempts against one token, or a redemption from a geography wildly different from the request.
Worked example
import secrets
import hmac
import hashlib
import time
import random
class ResetTokenStore:
"""Server-side store: never persists the raw token, only its hash, plus
expiry and a used flag for single-use enforcement."""
def __init__(self):
self._records = {} # token_hash -> {"user": ..., "expires_at": ..., "used": bool}
@staticmethod
def _hash(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode()).hexdigest()
def issue(self, user_id: str, ttl_seconds: int = 900) -> str:
raw_token = secrets.token_urlsafe(32) # 256 bits of CSPRNG entropy, URL-safe
self._records[self._hash(raw_token)] = {
"user": user_id,
"expires_at": time.time() + ttl_seconds,
"used": False,
}
return raw_token
def redeem(self, raw_token: str) -> str | None:
token_hash = self._hash(raw_token)
record = self._records.get(token_hash)
if record is None:
return None
if record["used"] or time.time() > record["expires_at"]:
return None
record["used"] = True # single-use enforcement
return record["user"]
def verify_constant_time(self, raw_token: str, claimed_hash: str) -> bool:
"""Illustrates comparing token hashes without a short-circuiting ==,
which would leak how many leading bytes matched via timing."""
return hmac.compare_digest(self._hash(raw_token), claimed_hash)
class WeakResetTokenGenerator:
"""The anti-pattern under test: a 6-digit numeric code seeded from a
non-cryptographic PRNG, the kind of 'looks fine in a demo' shortcut this
question is warning against."""
def __init__(self, seed):
self._rng = random.Random(seed) # NOT a CSPRNG
def generate(self) -> str:
return f"{self._rng.randrange(0, 1_000_000):06d}"
if __name__ == "__main__":
store = ResetTokenStore()
# --- correct path ---
token = store.issue("user-42", ttl_seconds=900)
print(f"issued token length: {len(token)} chars, generated from 32 random "
f"bytes = 256 bits of entropy (the specific characters are genuine "
f"CSPRNG output and differ on every run)")
user = store.redeem(token)
print(f"first redeem succeeds, resolves to user: {user}")
stored_hash = store._hash(token)
print(f"constant-time hash comparison result for the correct token: "
f"{store.verify_constant_time(token, stored_hash)}")
print(f"constant-time hash comparison result for a wrong token: "
f"{store.verify_constant_time('not-the-token', stored_hash)}")
user_again = store.redeem(token)
print(f"second redeem of the SAME token (replay): {user_again} (must be None, single-use holds)")
# --- expiry enforcement ---
short_lived_store = ResetTokenStore()
short_token = short_lived_store.issue("user-99", ttl_seconds=0)
time.sleep(0.01)
expired_result = short_lived_store.redeem(short_token)
print(f"redeem after expiry: {expired_result} (must be None)")
# --- attacking case: brute-force the weak 6-digit generator's keyspace
# (1,000,000 values), a search size that is realistically enumerable by
# an attacker hammering the reset endpoint if it isn't rate-limited ---
weak_gen = WeakResetTokenGenerator(seed=1)
real_code = weak_gen.generate()
print(f"\nweak generator's issued code: {real_code}")
guesses_tried = 0
found = False
for candidate in range(1_000_000):
guesses_tried += 1
if f"{candidate:06d}" == real_code:
found = True
break
print(f"brute-forced the 6-digit code in {guesses_tried} guesses "
f"(worst case {1_000_000}; this is why rate-limiting AND a large "
f"keyspace are both required, and why the numeric-code shortcut is unsafe "
f"without one)")
# --- contrast: the CSPRNG token's keyspace is 2^256; demonstrate the
# generator produces no repeats across a large honest sample (a collision
# would falsify the "effectively unique" claim; we do not claim the full
# keyspace is unsearchable, only report the observed collision count) ---
rng_check = [secrets.token_urlsafe(32) for _ in range(50_000)]
print(f"\ncollisions observed across {len(rng_check)} CSPRNG-generated tokens: "
f"{len(rng_check) - len(set(rng_check))}")
Running the correct path, the failure paths, and the attacking case against the weak generator:
issued token length: 43 chars, generated from 32 random bytes = 256 bits of entropy (the specific characters are genuine CSPRNG output and differ on every run)
first redeem succeeds, resolves to user: user-42
constant-time hash comparison result for the correct token: True
constant-time hash comparison result for a wrong token: False
second redeem of the SAME token (replay): None (must be None, single-use holds)
redeem after expiry: None (must be None)
weak generator's issued code: 140891
brute-forced the 6-digit code in 140892 guesses (worst case 1000000; this is
why rate-limiting AND a large keyspace are both required, and why the
numeric-code shortcut is unsafe without one)
collisions observed across 50000 CSPRNG-generated tokens: 0
The weak 6-digit numeric generator's entire keyspace (one million values) was exhaustively searchable in well under the worst case, exactly the risk a small, low-entropy code format carries if it is not paired with strict rate-limiting. The CSPRNG-based 256-bit token produced zero collisions across 50,000 samples, consistent with its far larger keyspace.
Trade-offs and pitfalls
A time-to-live that is too short frustrates legitimate users if email delivery is delayed; too long enlarges the attack window. A 15-to-60-minute range is a common balance, tuned to your email delivery service-level agreement (SLA). Rate-limiting the redemption endpoint too aggressively can let an attacker lock a legitimate user out of resetting their own account, a self-inflicted denial of service, since the account identifier itself may be attacker-supplied input; prefer exponential backoff or added friction (such as a CAPTCHA) over a hard lockout. A common real-world pitfall: hashing the raw token at the point of storage but also logging the raw token elsewhere in the request path (support tooling, error tracking, general request logs) undoes the entire design; audit every code path the token touches end to end, not just the database write.
An API signs JWT tokens using RS256, but a legacy endpoint accepts tokens with alg set to 'none' or allows algorithm confusion. Explain the vulnerability, outline a proof-of-concept exploit to forge a token accepted by the service, and specify code-level changes and validation checks to permanently fix the issue.
Sample Answer
Direct answer
The vulnerability is that the verifier trusts the alg field inside the token's own, attacker-controlled header to decide HOW to check the signature. An attacker can either set alg to none and strip the signature entirely, or set it to HS256 and sign the token with an HMAC (hash-based message authentication code) key derived from the server's PUBLIC RS256 key, which the attacker legitimately has, since it is public, tricking a verifier that reuses that public key as an HMAC secret into accepting a forged token. The permanent fix is for the server to pin the ONE algorithm it expects for a given key and never consult the token's own header to choose the verification algorithm.
Structured elaboration
- JSON Web Token (JWT) structure recap:
header.payload.signature, each segment base64url-encoded. The header names the algorithm (alg) and type, and critically, the RECEIVER decides how much to trust that field. - Attack 1, alg=none: the underlying JOSE (JSON Object Signing and Encryption) specification defines
noneas a legitimate algorithm meaning "unsigned." A verifier that dispatches on the header'salgand honorsnoneaccepts ANY payload with an empty signature segment, since there is nothing left to check. - Attack 2, RS256 to HS256 confusion: RS256 verification takes a PUBLIC key; HS256 verification takes a SHARED SECRET. If application code passes the same "key" variable into whichever verification function the header's
algselects, and that variable happens to be the RS256 public key, which is not secret by design, an attacker can compute a valid HMAC-SHA256 tag over a forged token using that public key as the HMAC secret. HS256 verification then checks whether the HMAC matches, and it does, because the attacker computed it correctly using the same "secret" the server is about to check against. - Proof of concept, outlined: (1) obtain the server's RS256 public key, typically published openly for exactly this purpose, (2) build a forged token header declaring
alg: HS256, (3) computeHMAC-SHA256(public_key_bytes, header + "." + payload)as the forged signature, (4) submit the resulting token to any endpoint whose verifier honors the header's algorithm choice. - The permanent fix: the verifier must be handed an explicit, expected algorithm (or a fixed small allow-list) out of band, from server-side configuration, and reject any token whose header does not match, never branch on the header's own claim. In practice, this means calling the JWT library's decode function with an explicit
algorithms=["RS256"]argument, not derived from the token, and never implementing a lookup table that mapsalgstrings to verification functions driven by attacker-controlled input. - Defense in depth: reject
noneoutright at the library and configuration level regardless of algorithm pinning; keep RSA (Rivest-Shamir-Adleman) signing keys and any HMAC secrets in clearly separate namespaces or vaults so accidentally handing one to the wrong verification call is structurally harder; add a permanent regression test that specifically attempts both forgeries against the real verifier.
Worked example
import base64, json, hmac, hashlib
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.exceptions import InvalidSignature
# ---- pinned RSA keypair (2048-bit, real keygen) ----
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
pub_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
payload = {"sub": "user-42", "role": "user"}
legit_token = jwt.encode(payload, priv_pem, algorithm="RS256")
print("legit RS256 token (truncated):", legit_token[:40], "...")
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
# ---- VULNERABLE verifier: dispatches on the attacker-supplied `alg` header,
# reusing one "key" for whichever code path the header names ----
def verify_vulnerable(token: str, key_material: bytes):
header_b64, payload_b64, sig_b64 = token.split(".")
header = json.loads(b64url_decode(header_b64))
signing_input = f"{header_b64}.{payload_b64}".encode()
alg = header["alg"] # attacker-controlled
if alg == "none":
pass # bug: "none" is honored, no signature check at all
elif alg == "HS256":
expected = hmac.new(key_material, signing_input, hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url_decode(sig_b64)):
raise ValueError("bad HMAC signature")
elif alg == "RS256":
pub = serialization.load_pem_public_key(key_material)
pub.verify(b64url_decode(sig_b64), signing_input, padding.PKCS1v15(), hashes.SHA256())
else:
raise ValueError(f"unsupported alg {alg}")
return json.loads(b64url_decode(payload_b64))
# ---- FIXED verifier: server pins the ONE expected algorithm, ignores the header ----
def verify_fixed(token: str, rsa_public_key_pem: bytes):
return jwt.decode(token, rsa_public_key_pem, algorithms=["RS256"])
# --- Attack 1: alg=none forgery -------------------------------------------------
forged_header = b64url(json.dumps({"alg": "none", "typ": "JWT"}).encode())
forged_payload = b64url(json.dumps({"sub": "user-42", "role": "admin"}).encode())
forged_none_token = f"{forged_header}.{forged_payload}." # empty signature segment
print("\n--- Attack 1: alg=none ---")
try:
forged_claims = verify_vulnerable(forged_none_token, pub_pem)
print("VULNERABLE verifier accepted forged token. Claims:", forged_claims)
except Exception as e:
print("VULNERABLE verifier rejected it:", e)
try:
verify_fixed(forged_none_token, pub_pem)
print("FIXED verifier: WRONGLY accepted (should not happen)")
except Exception as e:
print("FIXED verifier correctly rejected alg=none forgery:", type(e).__name__)
# --- Attack 2: RS256 -> HS256 algorithm confusion -------------------------------
# Attacker signs a token with HS256 using the SERVER'S PUBLIC KEY BYTES as the
# HMAC secret (public, so the attacker has it). verify_vulnerable's HS256
# branch reuses that same key material as an HMAC secret and will match.
forged_header2 = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
forged_payload2 = b64url(json.dumps({"sub": "user-42", "role": "admin"}).encode())
signing_input2 = f"{forged_header2}.{forged_payload2}".encode()
forged_sig2 = hmac.new(pub_pem, signing_input2, hashlib.sha256).digest()
forged_hs256_token = f"{forged_header2}.{forged_payload2}.{b64url(forged_sig2)}"
print("\n--- Attack 2: RS256->HS256 confusion ---")
try:
forged_claims = verify_vulnerable(forged_hs256_token, pub_pem)
print("VULNERABLE verifier accepted forged token. Claims:", forged_claims)
except Exception as e:
print("VULNERABLE verifier rejected it:", e)
try:
verify_fixed(forged_hs256_token, pub_pem)
print("FIXED verifier: WRONGLY accepted (should not happen)")
except Exception as e:
print("FIXED verifier correctly rejected HS256-confusion forgery:", type(e).__name__)
# --- Control: the fixed verifier still accepts the legitimate token ------------
print("\n--- Control: legitimate token against FIXED verifier ---")
claims = verify_fixed(legit_token, pub_pem)
print("FIXED verifier accepted the real token. Claims:", claims)
Running both attacks against the vulnerable dispatcher and the fixed, algorithm-pinned verifier:
legit RS256 token (truncated): eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ ...
--- Attack 1: alg=none ---
VULNERABLE verifier accepted forged token. Claims: {'sub': 'user-42', 'role': 'admin'}
FIXED verifier correctly rejected alg=none forgery: InvalidAlgorithmError
--- Attack 2: RS256->HS256 confusion ---
VULNERABLE verifier accepted forged token. Claims: {'sub': 'user-42', 'role': 'admin'}
FIXED verifier correctly rejected HS256-confusion forgery: InvalidAlgorithmError
--- Control: legitimate token against FIXED verifier ---
FIXED verifier accepted the real token. Claims: {'sub': 'user-42', 'role': 'user'}
Both forged tokens escalate role from user to admin and are accepted by the vulnerable dispatcher; both are rejected by the fixed verifier, which still correctly accepts the legitimate RS256 token.
Trade-offs and pitfalls
Modern JWT libraries (PyJWT 2.x among them) now refuse to let an application pass an asymmetric key into an HMAC code path, which closes attack 2 at the library level when the library's own high-level decode function is used correctly. Relying on that library default is not a substitute for an explicit algorithms=[...] allow-list at your own call site, though, since an older library version, a different language's library, or hand-rolled JOSE parsing (as shown in the vulnerable dispatcher above) will not have that guard. A common pitfall when "fixing" this: adding none to a denylist while still trusting the header for every other algorithm choice leaves attack 2's whole class of confusion open, any two algorithm types that structurally reuse the same key material remain exploitable; pin the expected algorithm explicitly rather than denylisting only the one attack already known about. CI regression tests for this class of bug age poorly if they only assert that alg=none is rejected and are never re-run after a library upgrade or a refactor of the verification call site; keep both forgery attempts as permanent, named regression tests.
A token service reuses a per-user HMAC key to sign both session tokens and password reset links. Identify cryptographic and protocol weaknesses arising from key reuse across different purposes and propose a secure key separation strategy and migration plan that preserves backward compatibility where possible.
Sample Answer
Direct answer
Reusing one HMAC (hash-based message authentication code) key to sign both session tokens and password-reset links means a signature legitimately produced for one purpose can, if the two message formats are not unambiguously distinguishable, be replayed or reinterpreted as valid for the OTHER purpose, a cross-protocol confusion attack. The fix is both deriving separate, purpose-scoped subkeys from the shared secret AND framing each signed message so no two distinct (purpose, payload) pairs can ever serialize to the same bytes.
Structured elaboration
- The core weakness class: with one key used across two message types built by naive string concatenation of
purpose + payload, two logically different inputs can produce byte-identical signed strings purely because of where the boundary between fields falls. An attacker holding a validly-issued signature for one message can find a DIFFERENT (purpose, payload) split that maps to the identical bytes, and that same signature verifies for the second purpose too. - Additional risk even without the framing bug: sharing one key at all means any future weakness discovered in how ONE consumer uses the key (a debug endpoint that echoes part of a signature, a length-extension-prone construction) exposes both consumers, not just the vulnerable one; purpose isolation limits blast radius the same way network segmentation does.
- Fix, part 1, key separation: derive a distinct subkey per purpose from the shared master secret using a key derivation function (KDF) such as HKDF (HMAC-based key derivation function), with the purpose string as the context input: a session-token subkey and a reset-link subkey. A signature computed under one subkey structurally cannot verify under the other, closing the cross-purpose path even if the framing bug is also present.
- Fix, part 2, unambiguous framing: length-prefix or otherwise unambiguously delimit each field before concatenation,
length(purpose) || purpose || length(payload) || payload, so no two distinct (purpose, payload) pairs can ever produce the same signed byte string. This closes the framing bug even if key separation somehow were not in place; the two fixes are complementary defense in depth, not alternatives to each other. - Migration plan preserving backward compatibility: version the token format (a leading version byte or field); issue all new tokens exclusively under the new derived-key, domain-separated scheme; keep verification of the OLD scheme's tokens working only until their natural expiry, reset links already expire quickly, session tokens can be given a bounded grace window; and monitor old-format verification volume, removing that code path once it reaches zero rather than picking an arbitrary cutover date.
Worked example
import hmac
import hashlib
import struct
def naive_sign(key: bytes, purpose: str, payload: str) -> bytes:
"""VULNERABLE: purpose and payload are just concatenated. Two different
(purpose, payload) pairs can produce the IDENTICAL signed bytestring."""
message = (purpose + payload).encode()
return hmac.new(key, message, hashlib.sha256).digest()
def domain_separated_sign(key: bytes, purpose: str, payload: str) -> bytes:
"""FIXED: length-prefix each field so no byte sequence is ambiguous
between two different (purpose, payload) splits, AND derive a
purpose-scoped subkey via HKDF so a session-token signature and a
reset-link signature are never even computed under the same key."""
subkey = hashlib.pbkdf2_hmac("sha256", key, purpose.encode(), 1) # stand-in for HKDF-Expand(key, purpose)
framed = struct.pack(">I", len(purpose)) + purpose.encode() + struct.pack(">I", len(payload)) + payload.encode()
return hmac.new(subkey, framed, hashlib.sha256).digest()
if __name__ == "__main__":
shared_key = b"user-42-per-user-secret-key-material"
# --- the confusion: two DIFFERENT logical messages collide because the
# naive concatenation has no boundary between purpose and payload ---
session_sig = naive_sign(shared_key, "session:", "adminuser")
reset_sig = naive_sign(shared_key, "session:a", "dminuser")
print("naive concatenation, two different (purpose, payload) pairs:")
print(f" sign('session:', 'adminuser') = {session_sig.hex()[:16]}...")
print(f" sign('session:a', 'dminuser') = {reset_sig.hex()[:16]}...")
print(f" signatures identical: {session_sig == reset_sig}")
# --- the concrete attack this enables: attacker holds a validly-issued
# reset-link signature for a payload they control, and re-presents it as
# a forged session token, if the verifier only checks the HMAC without
# confirming which purpose issued it (the missing check is exactly what
# a shared key make impossible to add cheaply) ---
attacker_controlled_reset_payload = "a:admin-session-hijack"
forged_reset_sig = naive_sign(shared_key, "reset:", attacker_controlled_reset_payload)
equivalent_session_sig = naive_sign(shared_key, "reset:a", ":admin-session-hijack")
print(f"\nattacker-obtained reset signature reused as a session signature: "
f"{forged_reset_sig == equivalent_session_sig}")
# --- the fix: domain-separated, purpose-scoped signing ---
fixed_session_sig = domain_separated_sign(shared_key, "session:", "adminuser")
fixed_reset_sig = domain_separated_sign(shared_key, "session:a", "dminuser")
print(f"\ndomain-separated construction, same ambiguous split as above:")
print(f" sign('session:', 'adminuser') = {fixed_session_sig.hex()[:16]}...")
print(f" sign('session:a', 'dminuser') = {fixed_reset_sig.hex()[:16]}...")
print(f" signatures identical: {fixed_session_sig == fixed_reset_sig} (must be False)")
# --- and purpose-scoped subkeys mean even IDENTICAL payloads signed for
# different purposes produce different tags, so a reset-link signature
# can never verify as a session-token signature at all ---
same_payload_session = domain_separated_sign(shared_key, "session", "adminuser")
same_payload_reset = domain_separated_sign(shared_key, "reset", "adminuser")
print(f"\nsame payload 'adminuser', purposes 'session' vs 'reset': "
f"signatures identical: {same_payload_session == same_payload_reset} (must be False)")
Running this with a shared per-user key and two different (purpose, payload) pairs that collide under naive concatenation:
naive concatenation, two different (purpose, payload) pairs:
sign('session:', 'adminuser') = 48280526e93ea281...
sign('session:a', 'dminuser') = 48280526e93ea281...
signatures identical: True
attacker-obtained reset signature reused as a session signature: True
domain-separated construction, same ambiguous split as above:
sign('session:', 'adminuser') = 4d703ee8c96f4310...
sign('session:a', 'dminuser') = 11020fd1286362cb...
signatures identical: False (must be False)
same payload 'adminuser', purposes 'session' vs 'reset': signatures identical: False (must be False)
Under naive concatenation the two logically distinct inputs sign to the identical value, meaning a signature the attacker legitimately obtained for one purpose verifies as the other purpose too. The domain-separated construction produces different signatures for the ambiguous split, and, independently, different signatures for the identical payload signed under two different purposes, confirming both fixes are doing real work.
Trade-offs and pitfalls
Deriving a purpose-scoped subkey adds a small, fixed computational cost per verification, one extra HMAC-based derivation, negligible compared to the cost of getting cross-purpose forgery wrong; do not skip it for performance reasons. A common pitfall in the migration plan: rotating the KEY without also fixing the FRAMING (or the reverse) leaves half the vulnerability in place; audit both independently, since a framing bug alone remains exploitable under separate purpose-scoped keys if two payloads for the SAME purpose can still collide, for example, no delimiter between a username field and a role field. Another pitfall: deriving the purpose-scoped subkey using ad hoc string concatenation into the KDF's context input, without also length-prefixing it, repeats the exact same class of bug one level up; use the KDF's own structured context parameter rather than manually gluing strings together anywhere in the design.
Unlock Full Question Bank
Get access to all 8 Cryptographic Implementation Security interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.