Security Advanced

Web Authentication Attacks — Sessions, JWT, OAuth, SSO, Account Takeover

How authentication actually breaks in modern web apps — session theft, JWT confusion, OAuth flaws, SSO race conditions, and the controls that actually work.

KC Ramo · DjangoZen Team May 10, 2026 19 min read 417 views

Why auth is special

Auth bugs convert directly to compromise. A SQLi reveals data. An XSS phishes users. An auth flaw lets the attacker BE another user — usually with admin somewhere in the chain. That's why auth deserves dedicated attention beyond the OWASP categories.

This tutorial covers the major auth attack patterns and the defenses that hold under real adversarial pressure.

Session management — what goes wrong

Even mature frameworks have edge cases.

Session fixation

The classic: attacker sets a session ID before the user logs in (via a malicious link, XSS, or just guessing). User logs in. Now attacker knows a valid post-auth session ID.

Defense: rotate the session ID on every privilege change (login, MFA verification, password change). Django does this in login() — don't override.

# Wrong (Django < 1.7 era; mistakes still happen)
request.session['user_id'] = user.id  # ID doesn't rotate

# Right (Django's auth.login)
from django.contrib.auth import login
login(request, user)  # Rotates session ID

Two details make this rotation work. login() calls request.session.cycle_key() when the session already belongs to the same user and flushes it when it belonged to someone else. And Django stores a hash derived from the password hash in the session; on each request the auth middleware compares it with user.get_session_auth_hash(), so a password change silently turns every other session anonymous. Custom login code that writes _auth_user_id by hand bypasses both protections.

If you add MFA, rotate again when the second factor succeeds. A common mistake is to call login() after the password check, mark the session "MFA pending", and later just flip a flag: the key issued before MFA then carries full privileges. Call request.session.cycle_key() at the moment MFA passes.

Baseline Django session settings

Most session hardening in Django is configuration. A production baseline for an HTTPS-only app on Django 5.2:

SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"  # server-side, revocable
SESSION_COOKIE_NAME = "__Host-sessionid"
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"      # "Strict" breaks OAuth/SSO callbacks
SESSION_COOKIE_AGE = 60 * 60 * 12    # 12 hours
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 60 * 60 * 24 * 365
SECURE_REFERRER_POLICY = "same-origin"
PASSWORD_RESET_TIMEOUT = 60 * 15     # seconds; default is three days

Browsers accept a __Host- cookie only if it is Secure, has Path=/ and no Domain, so a careless sibling subdomain cannot plant a session cookie for your app (renaming the cookie logs everyone out once). Avoid the signed_cookies engine for sensitive apps: the session lives entirely in the client, so there is nothing server-side to delete and "log out everywhere" is impossible until expiry. Note that SESSION_COOKIE_AGE alone is neither an idle nor an absolute timeout; enforcing both takes a small middleware that stores the login time and last activity in the session and calls logout() when either limit passes. Update the activity timestamp at most once a minute, or every page view becomes a session write.

Session prediction

Older systems used predictable session IDs (sequential, timestamp-based, weak randomness). Modern frameworks use cryptographically secure random IDs. Verify yours does — it's the difference between brute-forceable and not.

Cross-context session reuse

Same session ID accepted in different contexts (web + mobile + API). If one context is compromised, all are. Modern pattern: separate tokens per context, or bind tokens to specific clients via TLS fingerprints.

Long-lived sessions

A session that lasts months is a months-long window of compromise. After credential theft (via malware on user device), attacker has continuous access.

Defenses:

  • Short session age — 4 to 24 hours typical for sensitive apps
  • Absolute and idle timeouts both — even active users get re-auth periodically
  • Logout invalidates server-side, not just clears cookies
  • Token rotation within a session — issue new ID periodically, old one becomes invalid
  • Step-up auth for sensitive actions — re-enter password before changing email

Cross-device session listing

Users should see all their active sessions and revoke individually. Critical when their device is stolen and password change requires email access they can't get.

# Show user their active sessions
class UserSession(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    session_key = models.CharField(max_length=40)
    ip_address = models.GenericIPAddressField()
    user_agent = models.CharField(max_length=512)
    created_at = models.DateTimeField(auto_now_add=True)
    last_active = models.DateTimeField(auto_now=True)
    location = models.CharField(max_length=100, blank=True)

    def revoke(self):
        from django.contrib.sessions.models import Session
        try:
            Session.objects.get(session_key=self.session_key).delete()
        except Session.DoesNotExist:
            pass
        self.delete()

Populate this registry from the user_logged_in and user_logged_out signals, which fire for the admin, LoginView and any custom view that calls login(). Without it, finding a user's sessions means decoding every row of django_session, and with the cache backend it is impossible. If you write your own password-change view, call update_session_auth_hash(request, user) so the current device stays logged in while all others are evicted.

JWT — the popular but error-prone token

JSON Web Tokens are everywhere in modern auth. They're also one of the most consistently misimplemented protocols.

The structure

header.payload.signature

Each part is base64-encoded JSON. The signature is over the header + payload using a key (HMAC for symmetric, RSA/ECDSA for asymmetric).

Classic JWT bugs

alg: none — the JWT spec allows "alg": "none" for tokens with no signature. Some libraries accept these even when expecting signed tokens. Result: attacker crafts a JWT with any payload, no signature, library accepts it.

// Vulnerable verification
jwt.verify(token, key);  // Defaults may accept alg:none in old libraries

// Safe verification — always specify expected algorithms
jwt.verify(token, key, { algorithms: ['HS256'] });

Algorithm confusion (RS256 vs HS256) — server signs with RS256 (asymmetric); attacker takes the public key, signs a JWT with HS256 using the public key as the HMAC secret. If the server's verify accepts the same alg as the token claims, it verifies with the public key as the HMAC secret → valid signature.

Defense: pin the expected algorithm in verification. Never trust the alg from the token header.

Weak HMAC secret — short or guessable secret used for HS256 JWTs. Attacker brute-forces, then signs arbitrary tokens.

Defense: use long (32+ random bytes) secrets, store in HSM or secrets manager. Rotate periodically.

Missing expiration — JWTs are stateless. Once issued, can't be revoked without other mechanisms. Long-lived JWTs (>1 hour) without revocation = unhappy.

Defense: short expiration (15 minutes is common), refresh tokens for renewing, server-side blocklist for emergency revocation.

Sensitive data in payload — JWTs are base64-encoded, not encrypted. Anyone with the token can read the payload.

Defense: never put passwords, full PII, or anything beyond identifiers in payloads. Use the JWT as a key into your database for the real data.

A safer JWT pattern in Django

from rest_framework_simplejwt.settings import api_settings

# In settings.py
from datetime import timedelta

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'ALGORITHM': 'HS256',  # Pin algorithm
    'SIGNING_KEY': SECRET_KEY,  # already the default; use a separate long random key if you prefer
    'AUDIENCE': 'example-api',
    'ISSUER': 'example',
    'JTI_CLAIM': 'jti',  # For revocation by ID
}

BLACKLIST_AFTER_ROTATION means once you refresh, the old refresh token can't be reused — defeats refresh-token theft.

Blacklisting requires 'rest_framework_simplejwt.token_blacklist' in INSTALLED_APPS, and manage.py flushexpiredtokens on a schedule. The blacklist covers refresh tokens only; for an immediate kill switch on access tokens, add a per-user token_version claim and compare it in a JWTAuthentication subclass. simplejwt already loads the user on every request, so the check costs no extra query. Bump the version on password change, MFA reset and recovery.

Verifying tokens you did not issue

When your API accepts tokens from an external identity provider, verify with PyJWT and make every expectation explicit:

import jwt
from django.conf import settings

_jwks = jwt.PyJWKClient(settings.OIDC_JWKS_URL)  # module level: keys are cached


def verify_idp_token(token: str) -> dict:
    key = _jwks.get_signing_key_from_jwt(token).key
    return jwt.decode(
        token, key,
        algorithms=["RS256"],              # never from the token header
        audience=settings.OIDC_CLIENT_ID,  # rejects tokens minted for other apps
        issuer=settings.OIDC_ISSUER,
        leeway=30,                         # small clock skew
        options={"require": ["exp", "iat", "iss", "aud", "sub"]},
    )

The audience check is the one most often skipped: a token your IdP issued to a different application is signed by the same key and would otherwise verify perfectly. The JWKS URL must come from configuration, never from a jku or x5u header in the token.

OAuth 2.0 — when delegation goes wrong

OAuth lets users grant apps access to their accounts on other services without sharing passwords. It's standard for "Sign in with Google" patterns. It's also a vulnerability rich text format.

Implicit flow — don't use it

The old response_type=token flow returns access tokens directly in URLs. They leak to referrers, browser history, server logs.

Modern apps use authorization code flow with PKCE — the secure default.

Authorization code injection

Attacker tricks user into authorizing the legitimate app, captures the authorization code mid-flight, redeems it themselves.

Defense: PKCE (Proof Key for Code Exchange). The legitimate client generates a verifier; only it can exchange the code for tokens.

Redirect URI laxness

Apps that accept any redirect_uri value let attackers receive tokens at their own domain.

Defense: exact-match registered redirect URIs. No regex matching, no wildcard subdomains.

State parameter missing

The state parameter in OAuth is a CSRF defense — links a request to its response. Missing or unvalidated state allows CSRF in auth flows.

Defense: always include and validate the state parameter. Tie it to the user's session.

The client checks, and the provider settings

Prefer a maintained library (django-allauth, Authlib, mozilla-django-oidc), but know what it must do so you can review it: generate state, nonce and a PKCE verifier with secrets.token_urlsafe(); store them in the session; on the callback, pop them (one attempt per start) and compare with secrets.compare_digest; send the verifier in the code exchange; check the ID token's nonce. state binds the callback to the browser, the verifier binds the code to the client, and the nonce binds the ID token to this login. The callback is a cross-site navigation, so the session cookie must be SameSite=Lax, not Strict.

If your project is the authorization server with django-oauth-toolkit, set "PKCE_REQUIRED": True in OAUTH2_PROVIDER, restrict ALLOWED_REDIRECT_URI_SCHEMES to ["https"], keep ACCESS_TOKEN_EXPIRE_SECONDS short, enable ROTATE_REFRESH_TOKEN, and register new clients with the authorization-code grant only.

Consent screen phishing

Attacker registers an OAuth app with a name like "Your Company IT Support." Sends users a link to authorize. Users see "Authorize Your Company IT Support to access your account" and click yes. Attacker now has long-lived access token without ever knowing the password.

Defenses:

  • OAuth app approval workflows (admins approve new app integrations before users can authorize)
  • User education on OAuth consent screens
  • Periodic review of authorized OAuth apps
  • Revocation tooling

SSO — Single Sign-On bugs

SSO (SAML, OpenID Connect, OAuth-based) consolidates auth across services. It also consolidates failure modes.

SAML response replay

SAML responses can sometimes be replayed if not properly time-bound or one-use.

Defense: validate timestamps strictly (typically a 5-minute window), track response IDs to detect replays.

SAML XML signature wrapping

Attackers manipulate the XML structure so a malicious payload is processed while the signature still verifies against the original (signed) content.

Defense: validate that the signed XML element is the one being processed. This is a parser-level concern; use mature SAML libraries (python3-saml, pysaml2) and keep them updated.

The replay cache is often left to the application. It must be shared by every worker: Django's LocMemCache is per-process, so a replay landing on another worker passes. With Redis, cache.add(f"saml:seen:{assertion_id}", 1, timeout=...) is atomic and returns False when the ID was already seen. Also require signed assertions, not just a signed outer response, and pin the IdP certificate from reviewed metadata.

Account linking attacks

User has an account on your service with email user@example.com. Attacker creates a Google account user@example.com (or controls a corporate Google Workspace). Attacker clicks "Sign in with Google" on your service. Your service auto-links the Google identity to the existing local account.

Defense: never auto-link based on email alone. Require explicit verification (e.g., send email to the existing account asking to confirm linking).

The structural fix is to key federated identities on the pair (iss, sub), stored in its own model with a UniqueConstraint on both fields, never on email. Emails are reassigned when employees leave and some providers assert them unverified. When a new (iss, sub) matches an existing email, do not link: ask the person to sign in with their existing method and link from account settings after a recent re-authentication, then notify the account's email. For multi-tenant providers, compare the issuer exactly; accepting any tenant's issuer accepts sign-ins from every organisation on that provider.

Password reset — the bypass everyone forgets

The password reset flow is often the easiest auth bypass to find because:

  1. It's specifically designed to grant access without the current password
  2. It's exercised much less than login
  3. Edge cases accumulate

Token leakage in referer

Reset URL: https://yourapp.com/reset/?token=abc123. User clicks. Browser sends Referer: https://yourapp.com/reset/?token=abc123 to any external resource on the post-reset page. Token leaks.

Defense: strip query parameters after using the token. Redirect to a clean URL. Send a strict Referrer-Policy (Django's default same-origin never leaks the URL to other sites).

Token reuse

Tokens that don't get invalidated after use can be replayed.

Defense: one-use tokens. Invalidate immediately on consumption.

Token guessability

Random UUIDs (uuid4) are fine; time-based uuid1 values are not. Sequential IDs, short tokens, timestamps — not fine.

Account enumeration via reset

"If we have an account with that email, we sent a reset link." Good. "No account found with that email." → enumeration. Different response times → enumeration even with same message.

Defense: identical responses regardless of whether the email exists; identical timing (use a constant delay or async background processing).

Email spoofing in reset

Reset link generated with Host: attacker.com header → email goes to user, link is to attacker.

Defense: use canonical URLs from settings, not request-derived hosts.

What Django's reset flow already does

Django's built-in reset covers more of this list than many teams realise, which is a strong argument against a custom token table:

  • Effectively single use. PasswordResetTokenGenerator signs the user's pk, password hash, last login, email and a timestamp; setting a password or logging in changes the inputs and the old token stops validating.
  • Token leaves the URL. PasswordResetConfirmView moves the token into the session and redirects to a URL containing set-password instead.
  • No enumeration in the response. PasswordResetView redirects to the same page whether or not the address exists.

Two gaps remain. Mail is sent synchronously only for existing accounts, so enqueue it in a background task by overriding PasswordResetForm.send_mail(). And without django.contrib.sites the link's domain comes from request.get_host(); override save() to pass domain_override from settings, and keep ALLOWED_HOSTS strict.

Account takeover (ATO) defenses — putting it together

Layered controls that change ATO economics:

Login

  • Strong password rules (no max length, blocked breached passwords)
  • Rate limiting per IP and per account
  • MFA required (phishing-resistant for staff)
  • Anomaly detection (impossible travel, new device)
  • Step-up auth on sensitive actions

Session

  • Short session lifetime
  • Secure, HttpOnly, SameSite cookies
  • Device binding where possible
  • All sessions listable + revocable
  • Logout invalidates everywhere

Password reset

  • One-use tokens
  • Short expiry (10-15 minutes)
  • Email-based delivery only (never SMS)
  • Identical responses on enumeration probes
  • Step-up after reset (require additional verification before allowing email/MFA changes)

Recovery

  • Recovery methods enrolled before being needed (not after lockout)
  • Recovery via multiple factors (e.g., email + phone + backup codes)
  • Manual review for high-value accounts
  • Cool-down periods after recovery (limit actions for N hours after self-service recovery)

Detection

  • Logging of every auth event (success, failure, MFA, password change, email change)
  • Alerts on patterns: many failures, MFA bypass attempts, password change followed by email change
  • User-facing notifications: "Your password was changed"
  • Audit logging that survives account compromise

Detecting account takeover in Django

Django's user_logged_in, user_logged_out and user_login_failed signals are the natural place to write an append-only AuthEvent row (user, kind, IP, user agent, timestamp), with a SET_NULL foreign key so history survives account deletion. Log a hash of failed usernames, since people type passwords into that field, and ship events to your log pipeline so a database bug cannot erase them. Then alert on sequences, not single events:

PatternTypical meaningResponse
Failures across many accounts from few IPsCredential stuffingRaise per-IP friction; check for successes in the window
New-device success right after failuresStuffed password workedRequire MFA or email confirmation; notify the user
Password change, then email or MFA change within an hourTakeover consolidationHold the second change; notify the old email
Recovery, then payout, API key or exportTakeover via recoveryEnforce a cooldown; manual review
Same refresh token presented twiceToken theftRevoke the token family; force re-login

For recovery, store recovered_at on the user and block email change, MFA removal, API key creation and payout changes for 24 hours afterwards. On email change, send the old address a revert link built with django.core.signing.dumps(..., salt=...) and checked with loads(..., max_age=...); the revert should also revoke all sessions and tokens, because the attacker knows the current password. Keep one revoke_everything(user) function that ends sessions, refresh tokens, OAuth grants and API keys, so incident response is one call rather than a checklist.

Testing authentication defenses

Auth regressions hide because the happy path keeps working when a protection disappears. Assert the protections themselves:

from django.contrib.auth import get_user_model
from django.core import mail
from django.test import Client, TestCase
from django.urls import reverse


class AuthDefenseTests(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(
            "alice", "alice@example.com", "a-long-test-pass-1")

    def test_password_change_evicts_other_sessions(self):
        other = Client()
        other.force_login(self.user)
        self.user.set_password("another-long-pass-2")
        self.user.save()
        self.assertEqual(other.get(reverse("password_change")).status_code, 302)

    def test_reset_does_not_enumerate(self):
        a = self.client.post(reverse("password_reset"), {"email": "alice@example.com"})
        b = self.client.post(reverse("password_reset"), {"email": "nobody@example.com"})
        self.assertEqual(a["Location"], b["Location"])
        self.assertEqual(len(mail.outbox), 1)

Add equivalents for session key rotation on login, a callback with an unknown state returning 400, and verify_idp_token rejecting a wrong audience, an expired token and an unexpected algorithm. Run manage.py check --deploy against production settings in CI.

Why authentication is the prime target

Authentication is the gate to everything, which makes it the single most attacked part of most applications. If an attacker can become a legitimate user, they bypass nearly all other controls, so the mechanisms that verify identity — sessions, tokens, single sign-on flows — receive intense scrutiny. Every weakness in how you authenticate users, manage their sessions, or implement federated login is a potential path to account takeover. Understanding the specific ways authentication breaks is therefore disproportionately valuable, because hardening this one area protects against the outcome attackers most want: legitimate-looking access to accounts that are not theirs.

Session and token weaknesses

Sessions and tokens are how an application remembers a logged-in user, and flaws in handling them lead directly to hijacking. Predictable or exposed session identifiers, tokens that are not properly invalidated on logout, missing protections on session cookies, and tokens stored where scripts can steal them all let an attacker assume a user's session. The defenses are well established — secure cookie attributes, proper session lifecycle, short-lived tokens, careful storage — but each must be correct, because a single gap reopens the path to hijacking. Treating session and token management as a security-critical area deserving rigorous attention is what keeps authenticated sessions from becoming an attacker's entry point.

The complexity of federated login

Single sign-on and OAuth-based login improve user experience and can improve security, but they introduce complex flows where subtle misimplementations create vulnerabilities. Mishandled redirects, improperly validated tokens, trust placed in unverified data from a provider, and flawed account-linking logic have all led to takeovers. The convenience of "sign in with" comes with the responsibility to implement the underlying protocol correctly, validating everything and trusting nothing that has not been verified. Because these flows are intricate, relying on well-tested libraries and understanding the security-critical steps — rather than hand-rolling the protocol — is the safest path through the genuine complexity of federated authentication.

Closing thought

Authentication is the entire access control story compressed into a few minutes per user. Most web app breaches at the technical level are auth breaches at some layer — credentials, sessions, tokens, OAuth, SSO. Investing here pays off everywhere.

Tutorial 9 covers what happens when an attacker has authenticated as someone — the lateral movement, the persistence, the action-on-objective.