OAuth 2.0 and OpenID Connect Security: Common Vulnerabilities and How to Implement Them Correctly

Security tutorial - IT technology blog
Security tutorial - IT technology blog

The Day I Realized Authentication Was My Biggest Gap

After my server got hit by SSH brute-force attacks at midnight, I learned to prioritize security from day one. That incident pushed me to lock down every entry point — SSH keys, firewalls, fail2ban. But when I started building web apps and APIs, I discovered a much bigger gap: the authentication layer is where most real breaches actually happen. Not SSH. OAuth.

Misconfigured OAuth 2.0 flows sit behind a surprising number of serious data leaks — not because developers don’t care, but because the protocol has dozens of subtle settings. Most tutorials only show the happy path. Skip one option and everything looks fine until a pen tester — or an attacker — finds the hole.

This guide covers what goes wrong, what the core concepts actually mean, and how to implement them correctly.

What OAuth 2.0 and OpenID Connect Actually Are

Before you can fix problems, you need a clear mental model of what each protocol actually does.

OAuth 2.0 — Authorization, Not Authentication

OAuth 2.0 is a delegation protocol. It lets a user grant a third-party application limited access to their resources — without sharing their password. Think of it as a valet key: they can park your car, but they can’t open your trunk or get into the glove box.

The four main roles:

  • Resource Owner — the user who owns the data
  • Client — the application requesting access
  • Authorization Server — issues tokens (e.g., Google, GitHub, your own Keycloak)
  • Resource Server — the API holding the protected data

OpenID Connect — Authentication on Top of OAuth 2.0

OpenID Connect (OIDC) adds an identity layer on top of OAuth 2.0. OAuth 2.0 answers “can this app access this resource?” OIDC answers a different question: “who exactly is this user?”

OIDC introduces the ID Token — a signed JWT containing claims about the authenticated user: their subject ID, email, name, and when they logged in. The signature is what makes those claims trustworthy.

Building a “Sign in with Google” button? You’re using OIDC, whether you knew it or not.

The Most Common Vulnerabilities — and Why They Happen

1. Missing or Weak State Parameter

The state parameter exists to prevent CSRF attacks on the authorization flow. Without it, an attacker can trick a user’s browser into completing an OAuth exchange the attacker initiated — and your server has no way to tell the difference.

A vulnerable request looks like this:

# Vulnerable — no state parameter
https://accounts.google.com/o/oauth2/auth?
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  response_type=code&
  scope=openid email

Fix it by generating a cryptographically random value, binding it to the user’s session, and verifying it on return:

import secrets
import hashlib

def generate_state():
    # Cryptographically secure random state
    state = secrets.token_urlsafe(32)
    # Store in server-side session before redirecting
    session['oauth_state'] = state
    return state

def verify_state(received_state):
    expected_state = session.get('oauth_state')
    if not expected_state or not secrets.compare_digest(received_state, expected_state):
        raise ValueError("State mismatch — possible CSRF attack")
    del session['oauth_state']  # One-time use

2. Open Redirect via Unvalidated redirect_uri

Your authorization server must enforce an exact allowlist of redirect URIs. Accept partial matches or wildcards and attackers can redirect the authorization code straight to a server they control.

# Server-side: validate redirect_uri strictly
ALLOWED_REDIRECT_URIS = [
    "https://yourapp.com/callback",
    "https://yourapp.com/auth/callback",
]

def validate_redirect_uri(uri: str) -> bool:
    return uri in ALLOWED_REDIRECT_URIS

# Never do partial matching like:
# uri.startswith("https://yourapp.com")  <-- vulnerable to subdomain tricks

3. Authorization Code Interception — PKCE to the Rescue

Public clients — single-page apps, mobile apps — can’t safely store a client secret. If an attacker intercepts the authorization code before your app exchanges it, they can swap it for tokens themselves.

PKCE (Proof Key for Code Exchange, pronounced “pixie”) closes that gap. The client generates a random code_verifier and hashes it into a code_challenge. It sends the challenge upfront with the authorization request. When exchanging the code for tokens, it sends the original verifier. Only the legitimate client can produce both — an intercepted code alone is worthless.

import secrets
import hashlib
import base64

def generate_pkce_pair():
    # Step 1: generate code_verifier (43-128 chars, URL-safe)
    code_verifier = secrets.token_urlsafe(64)

    # Step 2: compute code_challenge = BASE64URL(SHA256(code_verifier))
    digest = hashlib.sha256(code_verifier.encode()).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

    return code_verifier, code_challenge

# Use in authorization request:
# &code_challenge=CODE_CHALLENGE
# &code_challenge_method=S256

# When exchanging code for token, send:
# &code_verifier=CODE_VERIFIER

Worth noting: PKCE is now recommended for all OAuth clients, not just public ones. Confidential server-side clients benefit too — it’s defense-in-depth even when a client secret exists.

4. Improper ID Token Validation

Using OIDC? You must validate the ID Token before trusting a single claim inside it. Skip this and you’re wide open to forged or replayed tokens.

Here’s every field you need to check:

  • iss (issuer) — must exactly match your provider’s expected issuer URL
  • aud (audience) — must contain your client ID
  • exp (expiration) — token must not be expired
  • iat (issued at) — should not be far in the past; also account for minor clock skew
  • nonce — must match the nonce you sent in the authorization request (blocks replay attacks)
  • Signature — verified using the provider’s public JWKS endpoint
from authlib.integrations.requests_client import OAuth2Session
from authlib.jose import jwt
import requests

def validate_id_token(id_token: str, nonce: str, client_id: str, issuer: str):
    # Fetch provider's public keys
    jwks_uri = f"{issuer}/.well-known/openid-configuration"
    oidc_config = requests.get(jwks_uri).json()
    jwks = requests.get(oidc_config['jwks_uri']).json()

    # Decode and verify
    claims = jwt.decode(id_token, jwks)
    claims.validate()  # validates exp, nbf

    # Manual claim checks
    assert claims['iss'] == issuer, "Issuer mismatch"
    assert client_id in claims['aud'], "Audience mismatch"
    assert claims.get('nonce') == nonce, "Nonce mismatch — replay attack?"

    return claims

Reach for a maintained library — authlib or python-jose — rather than hand-rolling JWT parsing. These protocols have edge cases that are easy to get wrong and nearly impossible to notice until something breaks in production.

5. Storing Tokens Insecurely on the Client

Storage location is one of the most underestimated decisions in browser-based apps:

  • localStorage / sessionStorage — readable by any JavaScript on the page. One XSS vulnerability and every token is gone.
  • HttpOnly cookies — JavaScript can’t touch them. This is the right call for access tokens in web apps.

Dealing with an SPA? Look at the BFF (Backend For Frontend) pattern. The browser talks to your backend, which holds tokens in server-side session and proxies API calls. Tokens never reach the browser at all.

Putting It Together: A Secure OAuth 2.0 Flow

Here’s a complete server-side OAuth 2.0 + OIDC implementation using Python, Flask, and authlib. State, PKCE, and nonce are all handled — nothing left to forget:

from flask import Flask, session, redirect, url_for, request
from authlib.integrations.flask_client import OAuth
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)  # Strong secret key

oauth = OAuth(app)
google = oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={
        'scope': 'openid email profile',
        'code_challenge_method': 'S256',  # Enable PKCE
    }
)

@app.route('/login')
def login():
    # Generate nonce for ID token replay protection
    nonce = secrets.token_urlsafe(16)
    session['oauth_nonce'] = nonce

    # authlib handles state + PKCE automatically
    redirect_uri = url_for('callback', _external=True)
    return google.authorize_redirect(redirect_uri, nonce=nonce)

@app.route('/callback')
def callback():
    # authlib verifies state automatically
    token = google.authorize_access_token()

    # Validate ID token with nonce
    nonce = session.pop('oauth_nonce', None)
    user_info = google.parse_id_token(token, nonce=nonce)

    # Store only what you need in session, not raw tokens
    session['user_id'] = user_info['sub']
    session['email'] = user_info['email']

    return redirect('/')

Token Lifetime and Refresh Strategy

Keep access tokens short-lived. Fifteen minutes to one hour is the common range — long enough to be useful, short enough to limit the damage if one leaks. Refresh tokens handle re-auth silently in the background. Store them server-side and rotate them on every use:

def refresh_access_token(refresh_token: str):
    response = requests.post(
        'https://oauth2.googleapis.com/token',
        data={
            'grant_type': 'refresh_token',
            'refresh_token': refresh_token,
            'client_id': CLIENT_ID,
            'client_secret': CLIENT_SECRET,
        }
    )
    data = response.json()
    # Save new access token and possibly new refresh token
    return data['access_token'], data.get('refresh_token', refresh_token)

A Few More Rules to Live By

  • Never put client secrets in frontend code — environment variables on the server, full stop
  • Always use HTTPS — OAuth over plain HTTP exposes tokens in transit
  • Scope minimization — request only the permissions you actually need
  • Audit your token scopes — a stolen access token with minimal scope limits how much damage gets done
  • Implement token revocation — on logout, revoke at the authorization server; clearing the session alone isn’t enough
def revoke_token(token: str):
    requests.post(
        'https://oauth2.googleapis.com/revoke',
        params={'token': token},
        headers={'Content-Type': 'application/x-www-form-urlencoded'}
    )

What Secure OAuth Implementation Actually Looks Like

The checklist is short: use PKCE, validate state and nonce, verify every ID token claim, store tokens in HttpOnly cookies or server-side session, keep access tokens short-lived. None of it is complicated. All of it matters.

The mistakes above aren’t theoretical. They show up in production apps — including ones built by experienced developers who trusted a “quick start” guide and never read past the happy path. Understanding the security model is what separates an implementation that’s genuinely secure from one that looks secure until it doesn’t.

My midnight SSH incident taught me that security problems don’t send warning emails. With authentication, get it right before someone else does the testing for you.

Share: