Skip to content

CWE-287: Improper Authentication - Python

Overview

In Django applications, authentication runs through AUTHENTICATION_BACKENDS, and improper authentication commonly appears as a custom backend whose authenticate() looks up a user and returns it without calling user.check_password() - or that compares the submitted password to user.password directly instead of through Django's password hashers. Flask applications built on Flask-Login or Flask-Security have the equivalent risk in a hand-written login view that skips the hash comparison. It also appears with PyJWT: calling jwt.decode() without an explicit algorithms allowlist, or with the removed verify=False flag / options={"verify_signature": False}, lets an unsigned or attacker-chosen-algorithm token pass as if it were verified.

The fix is to always verify through user.check_password() (or the framework's hasher) in custom backends, always pass algorithms=[...] to jwt.decode(), and never disable signature verification outside of clearly isolated, non-trust-boundary debugging code.

Common Vulnerable Patterns

Custom Django Backend That Skips check_password()

# VULNERABLE - compares the submitted password to the stored hash directly
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth import get_user_model

class EmailAuthBackend(BaseBackend):
    def authenticate(self, request, username=None, password=None, **kwargs):
        User = get_user_model()
        try:
            user = User.objects.get(email=username)
        except User.DoesNotExist:
            return None

        if user.password == password:  # compares a stored hash to plaintext input
            return user
        return None

# Attack example:
# Because user.password is always a hash string like "pbkdf2_sha256$...",
# this comparison never succeeds for a correct password either - but if the
# stored value was ever set to plaintext by another bug, any exact-string
# match authenticates with no hashing involved at all

Why this is vulnerable: Comparing user.password directly bypasses Django's password hashing entirely. Even when it "accidentally" fails safe today, it signals that the backend was never wired through check_password(), so any future code path that stores a plaintext or lightly-hashed value creates an immediate authentication bypass.

PyJWT Decoded Without an Algorithm Allowlist

# VULNERABLE - no algorithms argument; older/loosely configured PyJWT accepts the token's own alg
import jwt

def verify_token(token: str) -> dict:
    return jwt.decode(token, key=SIGNING_KEY, options={"verify_signature": False})

# Attack example:
# Any well-formed JWT, including one with alg: none and no signature at all,
# is decoded and its claims trusted - role: "admin" set by the attacker included

Why this is vulnerable: options={"verify_signature": False} (and the removed PyJWT 1.x verify=False flag) disables signature checking outright. Even without that option, omitting algorithms relies on library defaults rather than a hardcoded expectation, which is easy to get wrong across PyJWT versions and key types.

Flask Login View That Trusts a Client-Supplied Role

# VULNERABLE - reads a role from the request instead of the authenticated user record
@app.route('/admin')
def admin_panel():
    if request.cookies.get('role') == 'admin':
        return render_template('admin.html')
    return 'Forbidden', 403

# Attack example:
# Set-Cookie in browser dev tools: role=admin
# Result: attacker reaches the admin panel with no valid session at all

Why this is vulnerable: Nothing here confirms the request is even authenticated, let alone authorized - role is a plain client-supplied cookie. Identity and role must come from current_user/session, populated by a login flow that actually verified a credential.

Secure Patterns

Django Custom Backend That Verifies the Password Hash

# SECURE - check_password() runs Django's configured password hasher
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth import get_user_model

class EmailAuthBackend(BaseBackend):
    def authenticate(self, request, username=None, password=None, **kwargs):
        User = get_user_model()
        try:
            user = User.objects.get(email=username)
        except User.DoesNotExist:
            # Run the configured hasher once anyway, so an unknown address costs
            # what a wrong password costs. This is what ModelBackend does
            # (Django ticket #20760); a hand-written backend does not inherit it.
            User().set_password(password)
            return None

        if user.check_password(password) and user.is_active:
            return user
        return None

    def get_user(self, user_id):
        User = get_user_model()
        return User.objects.filter(pk=user_id).first()

Why this works: check_password() runs the submitted password through Django's configured PASSWORD_HASHERS (PBKDF2 by default, or Argon2 via Argon2PasswordHasher) and compares it against the stored hash using the hasher's own constant-time comparison - never a direct string comparison. Returning None on any failure lets Django's authenticate() fall through to the next configured backend instead of raising and short-circuiting the chain, and is_active prevents disabled accounts from authenticating even with a correct password.

User().set_password(password) in the DoesNotExist branch is what stops the response time from answering "does this address have an account". set_password() hashes into a throwaway, unsaved instance and the result is discarded; the point is the cost, which is the same cost check_password() pays. Measured on Django 6.1 with the default PBKDF2 hasher, a wrong password took 916 ms and an unknown address 0.2 ms without this line - a 4,200x gap - and 879 ms against 927 ms with it. django.contrib.auth.backends.ModelBackend already does this, so a finding against a project using the stock backend is a false positive; it is the custom backend that loses the protection. The helper's name depends on the version: from Django 6.1 it is check_password_with_timing_attack_mitigation(), and on 6.0 and earlier ModelBackend.authenticate runs the same set_password() inline in its own DoesNotExist branch. The protection is there on every release; only the named function is new. Every login now pays the full hashing cost, so rate-limit the endpoint.

PyJWT Decode With an Explicit Algorithm Allowlist

# SECURE - algorithms is explicit; signature, expiration, and format are all enforced
import jwt

def verify_token(token: str) -> dict:
    try:
        return jwt.decode(token, key=SIGNING_KEY, algorithms=["HS256"])
    except jwt.InvalidTokenError as exc:
        raise AuthenticationError("Invalid or expired token") from exc

Why this works: Passing algorithms=["HS256"] tells PyJWT to accept only that specific algorithm no matter what the token's own header claims, closing off algorithm-confusion attacks where a token is re-signed with a different key type. jwt.decode() with signature verification enabled (the default when algorithms is provided and options is not overridden) also enforces exp/nbf/iat automatically, and any failure raises jwt.InvalidTokenError (or a subclass) rather than returning partially-trusted claims.

Identity and Role Read Only From the Authenticated Session

# SECURE - Flask-Login's current_user reflects a verified session, not client input
from flask_login import login_required, current_user

@app.route('/admin')
@login_required
def admin_panel():
    if not current_user.is_admin:
        abort(403)
    return render_template('admin.html')

Why this works: @login_required rejects unauthenticated requests before the view body runs, and current_user is populated by Flask-Login from the server-side session established at login - not from any header or cookie value the client can set directly. current_user.is_admin reads a role from the authenticated user record fetched from the database, so an attacker cannot escalate privilege by editing request state.

Framework-Specific Guidance

Django - AUTHENTICATION_BACKENDS and Session Login

# settings.py
AUTHENTICATION_BACKENDS = [
    'myapp.backends.EmailAuthBackend',
    'django.contrib.auth.backends.ModelBackend',
]

# views.py
from django.contrib.auth import authenticate, login

def login_view(request):
    user = authenticate(request, username=request.POST['email'], password=request.POST['password'])
    if user is not None:
        login(request, user)  # login() rotates the session key internally
        return redirect('dashboard')
    return render(request, 'login.html', {'error': 'Invalid credentials'})

Django's login() calls request.session.cycle_key() internally, issuing a new session key on every successful login - Django's built-in defense against session fixation. A hand-rolled session-setting flow that sets the session itself, without going through login(), loses that protection.

Flask-Login / Flask-Security Session Handling

# SECURE - Flask-Login's login_user regenerates the session automatically when
# SESSION_PROTECTION is enabled, and Flask itself issues a new session cookie
app.config['SESSION_PROTECTION'] = 'strong'
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Strict'

from flask_login import login_user
from werkzeug.security import check_password_hash

# A real hash in the app's own format, used only to spend the same time on an
# unknown address. It has to be a genuine hash: check_password_hash() against ''
# or a malformed string returns in microseconds and the timing gap reopens.
DUMMY_HASH = ('scrypt:32768:8:1$xRRnOHKqnci0LHfe$'
              'd471fba2488a3370e6bf9611ceed019e63b980ab0c18522742b80e5c7ebc6224'
              'ea853cb55e3f5a1b47657c50290e0ff935591caf56d69233c8dce8bbac2979db')

@app.route('/login', methods=['POST'])
def login():
    user = User.query.filter_by(email=request.form['email']).first()
    password = request.form['password']
    # Hash on both branches, so the response time does not say which addresses exist
    verified = (user.check_password(password) if user
                else check_password_hash(DUMMY_HASH, password))
    if user and verified:
        login_user(user)
        return redirect(url_for('dashboard'))
    return render_template('login.html', error='Invalid credentials')

SESSION_PROTECTION = 'strong' ties the session to the client's IP and user agent and invalidates it on a mismatch, adding defense-in-depth against session hijacking. Pair this with login_user(), and avoid any code path that writes to session[...] directly before calling login_user() - doing so can carry pre-login session state into the authenticated session.

The else branch is the same fix the Django backend above needs, in the shape Flask puts it: if user and user.check_password(...) short-circuits on user and never reaches the hasher, so an unknown address is answered without paying for one. Measured with Werkzeug's default scrypt hasher, a wrong password took 495 ms and an unknown address 0.001 ms - roughly half a million to one - against 469 ms and 478 ms once both branches hash. Generate DUMMY_HASH once with generate_password_hash() and paste it in, so it stays a constant rather than a value that changes per process - and generate it with the same method and parameters as the stored hashes, since check_password_hash() reads both out of the hash string it is given. The scrypt dummy above standing in for pbkdf2 user hashes would restore the gap it was added to close.

Testing

  • Submit an incorrect password and confirm authenticate() returns None (Django) or the login view rejects the request (Flask), not a successful login.
  • Time three logins - known account with the right password, known account with a wrong password, unknown account - and assert all three are within noise of each other. A sub-millisecond answer for the unknown account is the enumeration oracle, and a re-scan cannot see it. Assert on the unknown-account status too: 401, not a 500 from a hasher handed None.
  • Craft a JWT with alg: none or a mismatched algorithm and confirm jwt.decode() raises InvalidAlgorithmError/DecodeError.
  • Submit an expired token (exp in the past) and confirm ExpiredSignatureError is raised.
  • Capture the session cookie before login, authenticate, and confirm the pre-login session ID no longer authenticates the account (session fixation test) - request.session.cycle_key()/login() in Django, SESSION_PROTECTION in Flask.
  • Re-scan with the security tool that originally reported the finding to confirm it no longer fires.

Common Pitfalls

  • Fixing the primary login backend but leaving a second, legacy backend earlier in AUTHENTICATION_BACKENDS that still does the unsafe comparison - Django tries backends in list order and stops at the first success, so an earlier vulnerable backend is reached before the fixed one ever runs.
  • Catching the exception jwt.decode() raises and falling back to a cached or default claims dict instead of rejecting the request - any exception from JWT verification must result in an authentication failure, never a silent downgrade to unverified data.
  • Setting options={"verify_exp": False} to work around a clock-skew issue instead of using PyJWT's leeway parameter for a bounded tolerance - disabling expiration checking removes the control entirely rather than adding tolerance.
  • Writing session data directly (session['user_id'] = user.id) instead of calling login()/login_user(), which skips the framework's session-key rotation and reintroduces session fixation even though the credential check itself is correct.

Dependencies and Installation

  • Django's built-in django.contrib.auth hashers require no extra package; add argon2-cffi (pip install argon2-cffi) and Argon2PasswordHasher to PASSWORD_HASHERS for new projects wanting Argon2id.
  • PyJWT (pip install pyjwt) - always pass algorithms=[...] explicitly; for RSA/EC-signed tokens, install pyjwt[crypto] to pull in the cryptography backend.
  • Flask-Login (pip install flask-login) for session-based Flask authentication, or Flask-Security-Too (pip install flask-security-too) for a more complete auth stack including registration and password reset flows.

JWT signing keys must come from a secret manager, or be injected into the process environment at start-up by the deployment platform - the environment is an injection mechanism, not a storage location (CWE-526). Never a literal in source: a committed key is readable by anyone with history access and cannot be rotated without a code change. Order matters in PASSWORD_HASHERS as well: Django hashes with the first entry and can verify against the rest, so a legacy hasher left at the top keeps producing weak hashes for every new password.

Migration Considerations

Switching a custom Django backend from a direct user.password comparison to check_password() will reject any stored value that isn't a hash Django's configured hashers recognize (for example a legacy plaintext or MD5 value from a prior system) - run a data audit and rehash or force a password reset for affected accounts before enforcing the new backend everywhere. Tightening PyJWT validation (adding algorithms, removing verify_signature: False) invalidates tokens that were previously accepted without full verification; coordinate the change with whatever issues the tokens and monitor for a spike in 401/403 responses during rollout. Moving session writes from manual session[...] assignment to login()/login_user() changes the session cookie value on every login going forward - any client-side code that cached the old session identifier for correlation needs to re-read it after login.

Additional Resources