Python secrets
last modified October 29, 2026 Python 3.15
In this article we show how to generate secure random values with the
secrets module.
The secrets module, added in Python 3.6 (PEP 506), generates
random numbers suitable for managing secrets such as passwords, account
authentication tokens, password reset links, and API keys. It uses the most
secure source of randomness the operating system provides, the same one used
by os.urandom. On Linux, this is the
getrandom system call.
The random module is designed for modelling and simulation, not for security. It uses the Mersenne Twister, a fast and well-tested generator whose output is completely predictable once enough of it has been observed.
Why not random
The Mersenne Twister has an internal state of 624 32-bit numbers. Each output is a scrambled (tempered) copy of one state number, and the tempering can be reversed. After seeing 624 consecutive outputs, an attacker can rebuild the state and predict every future value.
import random
def undo_right(y, shift):
x = y
for _ in range(32 // shift):
x = y ^ (x >> shift)
return x
def undo_left(y, shift, mask):
x = y
for _ in range(32 // shift):
x = y ^ ((x << shift) & mask)
return x & 0xffffffff
def untemper(y):
y = undo_right(y, 18)
y = undo_left(y, 15, 0xefc60000)
y = undo_left(y, 7, 0x9d2c5680)
return undo_right(y, 11)
secret_rng = random.Random()
observed = [secret_rng.getrandbits(32) for _ in range(624)]
state = tuple(untemper(y) for y in observed) + (624,)
clone = random.Random()
clone.setstate((3, state, None))
print('predicted:', [clone.randint(1, 100) for _ in range(8)])
print('actual: ', [secret_rng.randint(1, 100) for _ in range(8)])
The secret_rng generator is seeded from the operating system, so
we do not know its seed. We only observe 624 of its 32-bit outputs.
def untemper(y):
y = undo_right(y, 18)
y = undo_left(y, 15, 0xefc60000)
y = undo_left(y, 7, 0x9d2c5680)
return undo_right(y, 11)
The tempering applies four shift-and-XOR steps. We undo them in reverse order. Each step is inverted by repeating the operation until all bits are recovered.
state = tuple(untemper(y) for y in observed) + (624,) clone = random.Random() clone.setstate((3, state, None))
We load the recovered state into a new generator. The last element of the state tuple is the position in the state array; 624 means that the next call generates a fresh block. From now on, the clone produces the same numbers as the original.
$ python predict_random.py predicted: [9, 13, 1, 39, 82, 9, 64, 5] actual: [9, 13, 1, 39, 82, 9, 64, 5]
Tokens
The module has three functions for generating tokens: token_bytes,
token_hex, and token_urlsafe.
import secrets print(secrets.token_bytes(16)) print(secrets.token_hex(16)) print(secrets.token_urlsafe(16)) print(secrets.DEFAULT_ENTROPY) print(len(secrets.token_hex()), len(secrets.token_urlsafe()))
The argument is the number of random bytes, not the length of the result.
token_bytes returns raw bytes, token_hex encodes
them as hexadecimal digits, and token_urlsafe encodes them with
the URL-safe Base64 alphabet, without padding.
print(secrets.DEFAULT_ENTROPY) print(len(secrets.token_hex()), len(secrets.token_urlsafe()))
Without an argument, the functions use DEFAULT_ENTROPY bytes,
currently 32 (256 bits). The default may increase in future versions.
$ python tokens.py b'\xd3nB\xf9y"J\x9e\x9f>0Q:7\xf0c' c4c3bebea6f8b5b7b04499287371daf2 mNM8cpdUe-s8Io-KUv6muA 32 64 43
Token sizes
The table shows how the number of bytes relates to the length of the encoded tokens.
import secrets
print(f'{"bytes":>5} {"bits":>5} {"hex":>4} {"urlsafe":>8}')
for nbytes in (8, 16, 24, 32, 64):
hex_len = len(secrets.token_hex(nbytes))
url_len = len(secrets.token_urlsafe(nbytes))
print(f'{nbytes:>5} {nbytes * 8:>5} {hex_len:>4} {url_len:>8}')
Hexadecimal needs two characters per byte, while URL-safe Base64 needs four characters per three bytes. For tokens that must resist brute-force attacks, the documentation recommends at least 32 bytes. A 16-byte token (128 bits) is still far beyond any practical brute-force attack and is a common choice for session identifiers.
$ python token_sizes.py
bytes bits hex urlsafe
8 64 16 11
16 128 32 22
24 192 48 32
32 256 64 43
64 512 128 86
Random numbers and choices
The randbelow, randbits, and choice
functions cover the basic needs. For anything else, the module exports the
SystemRandom class.
import secrets
print(secrets.randbelow(6) + 1)
print(secrets.randbits(16))
print(secrets.choice(['rock', 'paper', 'scissors']))
pin = ''.join(secrets.choice('0123456789') for _ in range(6))
print(pin)
rng = secrets.SystemRandom()
cards = list(range(1, 11))
rng.shuffle(cards)
print(cards)
print(rng.sample(range(1, 50), 6))
print(rng.uniform(0, 1))
randbelow(n) returns an integer in the range [0, n), so we add 1
to simulate a die. randbits(k) returns an integer with
k random bits.
pin = ''.join(secrets.choice('0123456789') for _ in range(6))
A PIN is built from six random digits. We build it as a string, because a PIN can start with zeros.
rng = secrets.SystemRandom() cards = list(range(1, 11)) rng.shuffle(cards)
SystemRandom is a subclass of random.Random that
takes its numbers from the operating system. It provides all the familiar
methods, such as shuffle, sample, and
uniform.
$ python choices.py 5 14584 rock 526420 [10, 3, 6, 5, 7, 8, 4, 1, 9, 2] [7, 13, 25, 42, 2, 29] 0.3264859879027354
No seeds, no state
Seeding the Mersenne Twister makes its output reproducible, which is what we want in simulations and tests. A secure generator must not be reproducible.
import random
import secrets
a = random.Random(42)
b = random.Random(42)
print([a.randint(1, 9) for _ in range(5)])
print([b.randint(1, 9) for _ in range(5)])
rng = secrets.SystemRandom()
rng.seed(42)
print([rng.randint(1, 9) for _ in range(5)])
rng.seed(42)
print([rng.randint(1, 9) for _ in range(5)])
try:
rng.getstate()
except NotImplementedError as e:
print('NotImplementedError:', e)
Two Random objects with the same seed produce the same
sequence. For SystemRandom, the seed method does
nothing, and getstate raises NotImplementedError,
because the operating system source has no state that could be saved.
$ python no_seed.py [2, 1, 5, 4, 4] [2, 1, 5, 4, 4] [8, 4, 5, 9, 8] [2, 4, 6, 5, 9] NotImplementedError: System entropy source does not have state.
Generating passwords
A password generator picks characters from an alphabet. The following example is based on a recipe from the documentation.
import secrets
import string
def generate_password(length=16):
alphabet = string.ascii_letters + string.digits + string.punctuation
while True:
password = ''.join(secrets.choice(alphabet) for _ in range(length))
if (any(c.islower() for c in password)
and any(c.isupper() for c in password)
and sum(c.isdigit() for c in password) >= 2
and any(c in string.punctuation for c in password)):
return password
for _ in range(3):
print(generate_password())
print(generate_password(24))
The alphabet contains letters, digits, and punctuation. Some sites require passwords with certain character classes, so we generate passwords until one meets the requirements.
password = ''.join(secrets.choice(alphabet) for _ in range(length))
Each character is chosen independently. Rejecting passwords and starting over keeps the selection uniform; replacing individual characters to meet the rules would make some passwords more likely than others.
$ python password.py
KqrOD`OYl|b7V_4{
?2(:VyZ?T{rmT2ay
.8'[tlaLoRp[kv90
*nvzX&Z/hE`0ZrEiO&Lqv!04
Passphrases
A passphrase made of random words is easier to remember and type than a
random string. The words.txt file in this example contains 100
short English words, one per line.
import math
import secrets
from pathlib import Path
words = Path('words.txt').read_text().split()
def passphrase(count=6):
return '-'.join(secrets.choice(words) for _ in range(count))
print(len(words), 'words')
for count in (4, 6, 8):
bits = count * math.log2(len(words))
print(f'{passphrase(count):<62} {bits:.1f} bits')
print(f'7776-word list, 6 words: {6 * math.log2(7776):.1f} bits')
The strength of a passphrase comes from the random choice, not from the words. Each word chosen from a list of n words adds log2(n) bits of entropy.
bits = count * math.log2(len(words))
With our small list, each word adds only 6.6 bits. Real generators use larger lists, such as the 7776-word EFF Diceware list, where six words give about 77 bits.
$ python passphrase.py 100 words brick-acid-crystal-quartz 26.6 bits bloom-granite-quartz-kettle-lemon-velvet 39.9 bits cabin-kettle-ivory-nickel-brick-atlas-thistle-falcon 53.2 bits 7776-word list, 6 words: 77.5 bits
API keys and identifiers
Many services add a readable prefix to their API keys, so that leaked keys can be found by secret scanners and the environment is obvious.
import secrets
def new_api_key(prefix):
return f'{prefix}_{secrets.token_urlsafe(24)}'
keys = [new_api_key('live'), new_api_key('test')]
for key in keys:
print(key, len(key))
print(f'{secrets.token_hex(4)}-{secrets.token_hex(2)}')
print(hex(secrets.randbits(64)))
The prefix carries no secret information; the strength of the key comes from the 24 random bytes. The last two lines create short random identifiers for cases where uniqueness, not secrecy, is the goal. For general unique identifiers, see the uuid module.
$ python api_keys.py live_VuFks8Et3oTOYrdKCmfkF2PaJJgcdu9u 37 test_WHwFY58cv4IYt8h9mJglsoqrfcvRDQj2 37 2c2b6892-701b 0x89d7a9d83302d32b
Comparing secrets
The compare_digest function compares two strings or byte
sequences in time that does not depend on where they differ.
import secrets
API_KEY = 'xV7kQ2mN9pR4tL8w'
def check_key(supplied):
return secrets.compare_digest(supplied.encode(), API_KEY.encode())
print(check_key('xV7kQ2mN9pR4tL8w'))
print(check_key('xV7kQ2mN9pR4tL8x'))
print(check_key('short'))
print(secrets.compare_digest('abc', 'abc'))
try:
secrets.compare_digest('čaj', 'čaj')
except TypeError as e:
print('TypeError:', e)
The function returns True if the values are equal. Strings may
contain only ASCII characters; for other text, encode it to bytes first.
secrets.compare_digest is the same function as
hmac.compare_digest.
$ python compare.py True False False True TypeError: comparing strings with non-ASCII characters is not supported
Timing attacks
The == operator stops at the first difference. An attacker who
can measure how long a comparison takes can find out how many leading
characters of a guess are correct and recover a secret one character at a
time.
import secrets
import timeit
secret = b'a' * 100_000
early = b'b' + b'a' * 99_999
late = b'a' * 99_999 + b'b'
for name, guess in [('first byte wrong', early), ('last byte wrong', late)]:
eq = min(timeit.repeat(lambda: secret == guess, number=2000, repeat=5))
cd = min(timeit.repeat(lambda: secrets.compare_digest(secret, guess),
number=2000, repeat=5))
print(f'{name:<17} ==: {eq * 1000:7.2f} ms compare_digest: {cd * 1000:7.2f} ms')
We compare a 100 KB secret with two wrong guesses, one that differs in the first byte and one that differs in the last byte. The values are large to make the difference visible; for short tokens over a network the difference is tiny, but statistics over many requests can still reveal it.
cd = min(timeit.repeat(lambda: secrets.compare_digest(secret, guess),
number=2000, repeat=5))
The time of == depends on the position of the first difference,
while compare_digest always examines all bytes. It is slower,
which does not matter for short secrets.
$ python timing.py first byte wrong ==: 0.06 ms compare_digest: 105.60 ms last byte wrong ==: 4.71 ms compare_digest: 105.78 ms
Password reset tokens
A password reset link contains a token that proves the user can read the email. The token is a secret, so we store only its hash.
import hashlib
import secrets
from datetime import datetime, timedelta, UTC
store = {}
def create_reset_token(user):
token = secrets.token_urlsafe(32)
digest = hashlib.sha256(token.encode()).hexdigest()
store[user] = (digest, datetime.now(UTC) + timedelta(minutes=30))
return token
def verify_reset_token(user, token):
if user not in store:
return False
digest, expires = store[user]
if datetime.now(UTC) > expires:
return False
supplied = hashlib.sha256(token.encode()).hexdigest()
if secrets.compare_digest(supplied, digest):
del store[user]
return True
return False
token = create_reset_token('jane')
print(f'https://example.com/reset?token={token}')
print(store['jane'][0])
print(verify_reset_token('jane', 'guessed-token'))
print(verify_reset_token('jane', token))
print(verify_reset_token('jane', token))
The server stores a SHA-256 hash of the token and an expiration time. If the database leaks, the hashes cannot be used to reset passwords.
supplied = hashlib.sha256(token.encode()).hexdigest()
if secrets.compare_digest(supplied, digest):
del store[user]
return True
The supplied token is hashed and compared with the stored hash. Each token can be used only once, so we delete it after a successful check. The token is long and random, so a fast hash like SHA-256 is fine; user passwords need slow hashes such as bcrypt.
$ python reset_token.py https://example.com/reset?token=SB9UM-wOJkSlLHoFLJroSNOWeCRVw4rCzIg2jDXwHls 47728b443da467414a37ddcd5b7beb5123722ce59e19e78c8f30bdf96c2c355a False True False
Signed cookies
A web application can store data in a cookie and sign it with a secret key to detect tampering.
import hashlib
import hmac
import secrets
SECRET_KEY = secrets.token_bytes(32)
def sign(value):
mac = hmac.new(SECRET_KEY, value.encode(), hashlib.sha256).hexdigest()
return f'{value}.{mac}'
def unsign(cookie):
value, _, mac = cookie.rpartition('.')
expected = hmac.new(SECRET_KEY, value.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, expected):
raise ValueError('bad signature')
return value
cookie = sign('user=jane;role=editor')
print(cookie)
print(unsign(cookie))
tampered = cookie.replace('editor', 'admin')
try:
unsign(tampered)
except ValueError as e:
print('rejected:', e)
The key is 32 random bytes from token_bytes. In a real
application, it is generated once and loaded from configuration, so that it
survives restarts.
expected = hmac.new(SECRET_KEY, value.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, expected):
raise ValueError('bad signature')
When reading the cookie, we compute the HMAC of the value again and compare
it with the stored one. A user who changes the role to admin
cannot produce a valid signature without the key.
$ python signed_cookie.py user=jane;role=editor.658ee0421994b65f0e778f8008706c1681c68a9a3da85c60ec7e8759970972ca user=jane;role=editor rejected: bad signature
Source
secrets – Generate secure random numbers for managing secrets
PEP 506 – Adding A Secrets Module To The Standard Library
In this article we have generated secure tokens, passwords, and passphrases
with the Python secrets module.
Author
List all Python tutorials.