Implementing One-Time Tokens and the Pitfalls of max_age in Django Signing
django.core.signing is convenient. However, if you issue tokens relying solely on max_age, you may have serious security vulnerabilities.
🚫 max_age is Not "One-Time Use"
This is the most common misconception. The max_age of TimestampSigner only verifies the validity period.
loads(token, max_age=600)This token is valid for 10 minutes. During this 10 minutes, it can be successfully loaded 100 times or 1000 times.
This is because the signing module does not store any state.
What if a password reset link or email verification link can be used multiple times? That would be disastrous.
The "one-time use" feature must be implemented by us. Here are two common methods: DB and Cache.
1. DB Method (The Safest Approach)
Key: Use a DB flag (used) to manage the usage of the token.
This is strongly recommended for sensitive operations like password resets, account activations, and payment approvals.
1. Model Design
A model to store the status of the token is needed. Use UUID as a unique identifier (Nonce).
# models.py
import uuid
from django.db import models
from django.conf import settings
class OneTimeToken(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
nonce = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
used = models.BooleanField(default=False)
2. Token Creation
Create a "pre-use" record in the DB first. Then sign the nonce value of that record.
from django.core.signing import TimestampSigner
from .models import OneTimeToken
def create_one_time_link(user):
# 1. Create "pre-use" token record
token_record = OneTimeToken.objects.create(user=user)
# 2. Sign the unique identifier (nonce)
signer = TimestampSigner()
signed_nonce = signer.dumps(str(token_record.nonce))
return f"https://example.com/verify?token={signed_nonce}"
3. Token Verification
This logic is crucial.
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from .models import OneTimeToken
def verify_one_time_token(signed_nonce, max_age_seconds=1800): # 30 minutes
signer = TimestampSigner()
try:
# 1. Verify signature and expiration time
nonce_str = signer.loads(signed_nonce, max_age=max_age_seconds)
# 2. Query Nonce from DB
token_record = OneTimeToken.objects.get(nonce=nonce_str)
# 3. ★★★ Check if it has already been used ★★★
if token_record.used:
print("This token has already been used.")
return None
# 4. ★★★ Change status to "used" ★★★ (DB transaction recommended)
token_record.used = True
token_record.save()
# 5. Success: return the user
return token_record.user
except SignatureExpired:
print("The token has expired.")
return None
except (BadSignature, OneTimeToken.DoesNotExist):
print("The token is invalid.")
return None
- Advantages: Very secure and clear. Usage history is maintained in the DB for tracking.
- Disadvantages: DB I/O is involved. Old token records need to be periodically cleaned up.
2. Cache Method (Faster Approach)
Key: Register used tokens in the cache (e.g., Redis) as a "blacklist".
Suitable for scenarios where speed is crucial, such as "one-time view" links or preventing duplicate API calls with relatively low security risks.
1. Token Creation
Unlike the DB method, there are no additional operations at creation. Simply sign the data.
from django.core.signing import TimestampSigner
signer = TimestampSigner()
token = signer.dumps({"user_id": 123})
2. Token Verification (Using Blacklist)
After a successful loads, check the cache to see if "this token has been used".
from django.core.cache import cache
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
import hashlib
def verify_one_time_token_with_cache(token, max_age_seconds=1800):
signer = TimestampSigner()
try:
# 1. Verify signature and expiration time
data = signer.loads(token, max_age=max_age_seconds)
# 2. Generate cache key (hashing the token as it can be lengthy)
token_hash = hashlib.sha256(token.encode()).hexdigest()
cache_key = f"used_token:{token_hash}"
# 3. ★★★ Check if it's in the cache (blacklist) ★★★
if cache.get(cache_key):
print("This token has already been used.")
return None
# 4. ★★★ Register as "used" in the cache ★★★
# Store in the cache only for the original max_age
cache.set(cache_key, 'used', timeout=max_age_seconds)
# 5. Success: return data
return data
except SignatureExpired:
print("The token has expired.")
return None
except BadSignature:
print("The token is invalid.")
return None
- Advantages: Much faster than the DB. The TTL of the cache allows for automatic cleanup of expired tokens.
- Disadvantages: If the cache (like Redis) goes down or data is lost, non-expired tokens may be reused. (Less stability than DB)
Conclusion
| Method | Recommended Situation | Advantages | Disadvantages |
|---|---|---|---|
| DB | Password reset, payment, account verification | Safe, permanent record | DB load, requires cleanup |
| Cache | "One-time view" links, API duplicate prevention | Fast, automatic cleanup (TTL) | Reuse risk if cache is lost |
django.core.signing only verifies integrity and expiration. The "usage state" is the developer's responsibility. Choose a wise method based on your specific situation.
There are no comments.