secrets.token_hex(n) Does Not Return a Length-n Token (tested)

Every function in the secrets module takes an argument called n or nbytes, so it's easy to assume they all control the final string length the same way. We measured all three token functions with identical inputs, and each one returns a different length for the same n.

1. token_hex(n), token_urlsafe(n) and token_bytes(n) all return different lengths for the same n

import secrets

for n in (1, 2, 3, 8, 16, 32):
    print(n, len(secrets.token_hex(n)), len(secrets.token_urlsafe(n)), len(secrets.token_bytes(n)))
n   token_hex  token_urlsafe  token_bytes
1   2          2              1
2   4          3              2
3   6          4              3
8   16         11             8
16  32         22             16
32  64         43             32

Measured: all three functions generate n random bytes underneath -- n always means "bytes of entropy," never "characters of output." token_hex() then renders those bytes as two hex characters each, so the string is always 2*n characters. token_urlsafe() base64-encodes them (no padding), giving roughly 4*n/3 characters, rounded up. Only token_bytes() returns something of length exactly n -- and that's raw bytes, not a printable string. If you need a token that fits a fixed-width column or URL slug, you have to work backward from the target length to the right n, not pass the target length directly.

2. secrets.compare_digest() crashes on a str/bytes mismatch instead of just returning False

import secrets

stored_token = secrets.token_bytes(16)       # e.g. loaded from a database BLOB column
submitted_token = stored_token.hex()         # e.g. received as a string from a web form

print(submitted_token == stored_token)       # naive comparison
print(secrets.compare_digest(submitted_token, stored_token))
False
Traceback (most recent call last):
  ...
TypeError: a bytes-like object is required, not 'str'

Measured: plain == silently returns False when comparing a string to bytes -- wrong, but not a crash. secrets.compare_digest() (an alias for hmac.compare_digest()) refuses to compare mismatched types at all and raises TypeError in both argument orders. This matters in practice: swapping a naive == for compare_digest() is a common, correct security fix for timing-safe comparison, but if the two values aren't stored in the same representation (one as a hex string, one as raw bytes), the fix itself becomes a new crash in production rather than a silent bug.

3. The fix: generate and compare in the same representation throughout

import secrets

# Pick ONE representation and stick to it end to end -- here, a hex string:
new_token = secrets.token_hex(16)            # str, 32 chars
# ... store new_token as TEXT, send it as a string, compare it as a string ...
is_valid = secrets.compare_digest(new_token, submitted_token_as_str)

Measured takeaway: decide up front whether tokens live as hex strings, URL-safe strings, or raw bytes, and keep that representation consistent through storage, transmission and comparison. token_hex() is usually the simplest choice since the result is plain ASCII, safe for any text column, URL, or JSON field, with no base64 padding edge cases to think about.

Frequently asked questions

Does secrets.token_hex(16) return a 16-character token?

No -- we measured this: it returns 32 characters, because n=16 means 16 random bytes, and each byte becomes two hex characters. The output is always 2*n characters long.

Does secrets.token_urlsafe(n) return the same length as token_hex(n)?

No -- we measured both with identical n values and got different lengths every time. token_urlsafe() base64-encodes the n bytes instead of hex-encoding them, giving roughly 4*n/3 characters (rounded up), versus token_hex()'s 2*n.

Why does secrets.compare_digest() raise a TypeError instead of just returning False?

We confirmed it refuses to compare a string against bytes in either argument order, raising TypeError('a bytes-like object is required...'). Plain == would have just returned False silently; compare_digest() treats a type mismatch as an error instead.

I switched == to secrets.compare_digest() for security and now get a crash -- why?

We reproduced this exact scenario: compare_digest() requires both arguments to be the same type (both str or both bytes). If one side is a hex string and the other is raw bytes from storage, the timing-safe comparison raises TypeError where == would have silently (and incorrectly) returned False.

What's the safest way to generate and compare tokens with the secrets module?

Pick one representation -- we'd suggest secrets.token_hex() for plain-ASCII, URL- and column-safe strings -- and keep every token in that same representation from generation through storage to comparison with secrets.compare_digest().

Written with AI assistance. Measured on 2026-10-04 with Python 3.13.5's built-in secrets module: called token_hex(), token_urlsafe() and token_bytes() with the same n across six values (1, 2, 3, 8, 16, 32) and recorded the exact output length for each, then measured secrets.compare_digest() (an alias for hmac.compare_digest()) against mismatched str/bytes arguments. PlainHeaders is our extension; Python's secrets module is built in.