ZetCode

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.

predict_random.py
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.

tokens.py
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.

token_sizes.py
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.

choices.py
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.

no_seed.py
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.

password.py
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.

passphrase.py
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.

api_keys.py
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.

compare.py
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.

timing.py
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.

reset_token.py
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.

signed_cookie.py
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.