A == comparison on a hash is not enough to pick the right mechanism. sha256, HMAC, salted hash, encryption: each approach offers different guarantees. Understanding which ones changes concretely how you store and verify a token in Django.
Simple hash
import hashlib
token_hash = hashlib.sha256(token.encode()).hexdigest()
A simple hash is deterministic: the same input always produces the same output. No server secret is involved. It is impossible to recover the original token from the hash (sha256 is a one-way function). But if someone knows or guesses the token, they can recompute the hash and compare.
That is the main problem: if tokens are short, predictable, or drawn from a limited space, an attacker with a database dump can test values offline at very high speed, without ever querying the server.
Salted hash
import hashlib
import os
salt = os.urandom(16).hex() # generated and stored per row
token_hash = hashlib.sha256((salt + token).encode()).hexdigest()
A salt is added: a random value unique to each row. Two identical tokens will produce different hashes if their salts differ. This destroys the effectiveness of rainbow tables and forces the attacker to process each row separately.
For passwords, dedicated algorithms like bcrypt or argon2 go further: they integrate the salt natively and add a cost factor (intentional slowness) that makes GPU brute-force attacks prohibitive. A manual sha256 + salt remains fast to compute, which is a weakness for passwords.
In all cases, if the salt is stored in plaintext in the same table and there is no server secret, protection remains limited: a full dump exposes salt + hash, and a patient attacker can still test candidates offline.
HMAC
import hmac
import hashlib
mac = hmac.new(
key=server_secret.encode(),
msg=token.encode(),
digestmod=hashlib.sha256,
).hexdigest()
HMAC introduces a server secret. Without that secret, it is impossible to recompute the correct value. An attacker who obtains the database dump cannot recover the token or verify candidates: the secret is missing.
In Django, salted_hmac from django.utils.crypto relies on SECRET_KEY as its implicit secret:
from django.utils.crypto import salted_hmac
token_hmac = salted_hmac("auth-token-salt", token).hexdigest()
By default, salted_hmac uses SHA-1 (for historical compatibility). Since Django 3.1, an algorithm parameter allows specifying SHA-256:
token_hmac = salted_hmac("auth-token-salt", token, algorithm="sha256").hexdigest()
Verification is straightforward: recompute the HMAC of the token provided by the client and compare it with the stored value, in constant time using constant_time_compare.
Encryption
from cryptography.fernet import Fernet
key = Fernet.generate_key()
f = Fernet(key)
encrypted = f.encrypt(token.encode())
decrypted = f.decrypt(encrypted) # recovers the original token
Encryption is reversible: the server can recover the original value. This is useful when you need to read the data back later, for example an API key to display once or an OAuth token to refresh.
For verifying an authentication token, this is unnecessarily risky: you do not need to retrieve the token, only confirm that it is valid. Storing a decryptable value in the database widens the attack surface with no benefit.
Summary
| Mechanism | Server secret | Reversible | Typical use |
|---|---|---|---|
| Simple hash | No | No | Fingerprint, deduplication |
| Salted hash (sha256 + salt) | No | No | Basic storage, not recommended for passwords |
| bcrypt / argon2 | No | No | Passwords (intentional slowness) |
| HMAC | Yes | No | Token verification |
| Encryption | Yes | Yes | Value must be read back later |
Why HMAC is the right choice for a Django token
The pattern is: generate a raw token, send it to the client, store its HMAC in the database, then verify on each request by recomputing the HMAC of the provided token.
import secrets
from django.utils.crypto import constant_time_compare, salted_hmac
TOKEN_SALT = "auth-token-v1"
# Generation
raw_token = secrets.token_urlsafe(32)
stored_hash = salted_hmac(TOKEN_SALT, raw_token, algorithm="sha256").hexdigest()
# → store stored_hash in the database, return raw_token to the client
# Verification
def verify_token(provided_token: str, stored_hash: str) -> bool:
expected = salted_hmac(TOKEN_SALT, provided_token, algorithm="sha256").hexdigest()
return constant_time_compare(expected, stored_hash)
If the database is compromised, the attacker gets HMACs but not the raw tokens or SECRET_KEY. Without that secret, those values are useless: the attacker cannot recover the tokens or forge new ones.
This is precisely the HMAC use case: generate a raw token, send it to the client, keep nothing readable in the database, and verify later. Encryption would be a weaker guarantee for this need, not a stronger one.
