A == comparison on a token looks harmless. In practice, it leaks a measurable piece of information: execution time varies depending on how many characters match. That is the principle behind a timing attack, and it is enough for an attacker to reconstruct the token one character at a time.

The problem: the comparison that stops too early

Python compares strings character by character and stops as soon as a mismatch is found.

def verify_token(request):
    user_token = request.GET.get('token')
    valid_token = "secret_abc123"

    if user_token == valid_token:
        return JsonResponse({'access': 'granted'})
    return JsonResponse({'access': 'denied'})

In practice:

  • user_token = "x" fails at the first character, returns almost instantly.
  • user_token = "secret_ab" fails at the 10th character, returns slightly later.

The difference is in the nanosecond range, but it becomes measurable with enough requests. An attacker automates thousands of calls, measures response times, and deduces the token character by character. This is a timing attack, a side-channel attack that exploits execution time variations.

The fix: constant_time_compare()

Django provides constant_time_compare() in django.utils.crypto. This function compares two strings in constant time: the result takes exactly the same amount of time to compute regardless of how similar the two values are.

from django.utils.crypto import constant_time_compare

def verify_token(request):
    user_token = request.GET.get('token', '')
    valid_token = "secret_abc123"

    if constant_time_compare(user_token, valid_token):
        return JsonResponse({'access': 'granted'})
    return JsonResponse({'access': 'denied'})

Two important details in this fix:

  • request.GET.get('token', '') instead of get('token'): prevents a None value from raising an exception during the comparison.
  • valid_token must never be hardcoded in production. It should come from an environment variable or a secret manager.

Under the hood: hmac.compare_digest

constant_time_compare() is a wrapper around hmac.compare_digest() from the Python standard library. This function guarantees identical execution time by comparing bytes without short-circuiting.

import hmac

# Direct equivalent outside Django
if hmac.compare_digest(user_token, valid_token):
    ...

In a Django project, constant_time_compare() is preferred because it normalizes input types and handles edge cases such as Unicode string comparison.

Other use cases

This protection applies to any context where a secret comparison is involved.

Webhook signature verification:

from django.conf import settings
from django.utils.crypto import constant_time_compare
import hashlib
import hmac

def verify_webhook(request):
    payload = request.body
    received_sig = request.headers.get('X-Hub-Signature-256', '')
    expected_sig = 'sha256=' + hmac.new(
        settings.WEBHOOK_SECRET.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    if not constant_time_compare(received_sig, expected_sig):
        return HttpResponse(status=403)
    # process the webhook

API key verification:

from django.utils.crypto import constant_time_compare

def api_key_middleware(get_response):
    def middleware(request):
        provided_key = request.headers.get('X-Api-Key', '')
        if not constant_time_compare(provided_key, settings.API_SECRET_KEY):
            return JsonResponse({'error': 'unauthorized'}, status=401)
        return get_response(request)
    return middleware

When this risk is real

ContextRisk level
Internal API, local network⚠️ High: low latency, precise measurements
Public API over the internet⚠️ Moderate: feasible with enough requests
Non-confidential value (slug, status, public ID)✅ No risk: == is fine

The decision rule is simple: whenever a comparison involves a secret (token, signature, API key), use constant_time_compare(). The performance cost is negligible, the protection is real.