OAuth 2.0 and OpenID Connect: Fundamentals of Authorization and Authentication

OAuth 2.0 vs OpenID Connect explained: authorization code flow, implicit flow, access tokens vs ID tokens, PKCE, and security best practices with sequence diagrams.

Introduction

In web applications, user authentication (verifying identity) and authorization (granting permissions) are the most critical security functions.

  • Authentication: “Who are you?” → Verify user identity
  • Authorization: “What can you do?” → Control access to resources

OAuth 2.0 is a framework for authorization, while OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. This article explains how each works and the security considerations to keep in mind.

OAuth 2.0 Overview

Four Roles

OAuth 2.0 defines four roles:

RoleDescriptionExample
Resource OwnerOwner of the resourceEnd user
ClientApplication accessing the resourceWeb app, mobile app
Authorization ServerServer that issues tokensGoogle, Auth0
Resource ServerProvides protected resourcesAPI server

Client Registration

To use OAuth, you must register your client with the Authorization Server and obtain:

  • client_id: Client identifier (public)
  • client_secret: Client secret key (confidential, server-side only)
  • redirect_uri: URL to redirect after authorization

OAuth 2.0 Grant Types

Authorization Code Grant

The most common flow for web applications.

1. Client → Authorization Server: Authorization request
   GET /authorize?response_type=code
     &client_id=CLIENT_ID
     &redirect_uri=REDIRECT_URI
     &scope=read write
     &state=RANDOM_STATE

2. Resource Owner: Login & consent

3. Authorization Server → Client: Return authorization code
   302 Redirect to REDIRECT_URI?code=AUTH_CODE&state=RANDOM_STATE

4. Client → Authorization Server: Token exchange (back channel)
   POST /token
     grant_type=authorization_code
     &code=AUTH_CODE
     &redirect_uri=REDIRECT_URI
     &client_id=CLIENT_ID
     &client_secret=CLIENT_SECRET

5. Authorization Server → Client: Return access token
   { "access_token": "...", "token_type": "bearer", "expires_in": 3600 }

Why This 5-Step Design: Separating the Front Channel from the Back Channel

The fact that the authorization code flow routes through two distinct tokens — the authorization code and the access token — is not an accidental design choice. Steps 1-3 travel over the front channel (a path that passes through browser redirects), while steps 4-5 travel over the back channel (direct HTTPS communication between the client and the Authorization Server that never touches the browser). This separation of channels is the core of OAuth 2.0’s security model.

The front channel (the browser URL) can leak in principle through several paths:

  • Browser history: URLs are recorded by the browser and can be seen by third parties via shared devices or history-sync services
  • Referrer header: if the redirected-to page loads external resources (images, scripts, ad tags, etc.), the entire current URL may be sent as a Referer header
  • Server access logs: web servers typically record the full request URL (including query parameters) in access logs, which can leak to anyone with log access
  • Intermediate proxy and CDN logs

Because of this, OAuth 2.0’s designers adopted the policy of only letting “a low-value, short-lived, single-use value that has no worth on its own” cross the front channel. That value is the authorization code. Authorization codes are subject to the following constraints (RFC 6749 §4.1.2):

  • Extremely short lifetime (recommended maximum of around 10 minutes; some implementations use tens of seconds to 1 minute)
  • Single use (once a code is used for token exchange, the Authorization Server must immediately invalidate it. If a second token-exchange attempt is made with the same code, best practice recommends revoking all tokens already issued from that code — to minimize damage once a leak is discovered)
  • The client_id / redirect_uri presented at exchange time must match those used at issuance (preventing diversion to other clients)

The access token, on the other hand — the one that is actually long-lived and represents real API access rights — is exchanged only over the back channel (server-to-server communication protected by TLS) and never appears in a browser URL or log. In other words, the design principle behind this flow is risk localization through channel separation: let only a low-value token (the authorization code) cross the front channel, and hand over the high-value item (the access token) only within the back channel.

This perspective also explains, logically, why the Implicit Grant was deprecated, discussed below. The Implicit Grant skips back-channel communication and returns the access token directly over the front channel (the URL fragment). But this exposes the very thing that most needs protection — the access token — to the front-channel leak paths described above (history, referrer, logs). While the URL fragment (everything after #) is not sent to the server and therefore tends not to appear in referrers or server logs, it remains defenseless against browser history, browser extensions, and malicious in-page JavaScript (XSS reading window.location.hash), and it also cannot issue refresh tokens (since there is no back channel). OAuth 2.0 Security Best Current Practice (RFC 9700) explicitly states that the Implicit Grant “MUST NOT” be used, and the industry has consolidated on the authorization code flow with PKCE.

Authorization Code + PKCE

Why Is the Authorization Code Interception Attack Possible?

The authorization code flow reduces risk by only letting a single-use, short-lived voucher cross the front channel. However, public clients (clients that cannot securely hold a client_secret, such as SPAs and mobile apps) are left with a different weakness: if that voucher itself is intercepted, the entire flow collapses.

A typical attack scenario looks like this:

  1. Mobile apps frequently use a custom URI scheme as the redirect_uri (e.g., myapp://oauth/callback)
  2. Custom URI schemes are registered at the OS level to determine “which app receives this,” but on some OS versions/configurations multiple apps can register the same scheme (scheme ownership is not verified)
  3. If a malicious app registers the same myapp://oauth/callback for itself, it can intercept the redirect from the legitimate Authorization Server and steal the code=AUTH_CODE
  4. Because the legitimate client cannot securely embed a client_secret (analyzing the app binary would expose any embedded secret), knowing only the client_id is enough to submit a normal token-exchange request using the stolen authorization code

This problem stems from the fact that the implicit assumption of the authorization code flow — “the authorization code is protected as confidential information” — does not hold for public clients. In other words, the root cause is that there is no way to prove that whoever is submitting the token-exchange request is really the same party that initiated the authorization request.

The PKCE Solution: code_verifier as a Proof of Possession

PKCE (RFC 7636) fills this missing piece — proof of identity — with a disposable secret known only to the client (a proof), without introducing any new TLS layer or secret key. The steps are:

  1. Before sending the authorization request, the client generates a code_verifier from a cryptographically secure random source (RFC 7636 recommends 43-128 characters from [A-Z a-z 0-9 - . _ ~]; in practice, roughly 256 bits of entropy is common)
  2. It computes code_challenge = BASE64URL(SHA256(code_verifier)) (with code_challenge_method=S256)
  3. Only the code_challenge is included in the authorization request (the code_verifier itself is not sent anywhere yet)
  4. The Authorization Server stores the code_challenge bound to the authorization code and issues the code
  5. During token exchange, the client sends the code_verifier in plaintext for the first time
  6. The Authorization Server recomputes SHA256BASE64URL from the received code_verifier and checks it against the code_challenge stored in step 4

The logical reason this defeats the attack breaks down as follows:

  • All an attacker can intercept is the authorization code and the code_challenge (a hash value); the code_verifier (the raw random value) never crosses the front channel (URL/redirect) — it exists only in the legitimate client’s memory during steps 1-3
  • Reversing code_challenge back to code_verifier would require breaking SHA-256’s preimage resistance. SHA-256’s output space has 2^256 possibilities, and if the code_verifier carries roughly 256 bits of entropy, brute-forcing a matching code_verifier is computationally infeasible
  • Therefore, even if an attacker attempts a token exchange with an intercepted authorization code, they cannot present the correct code_verifier, and the Authorization Server’s check (step 6) will always reject it

In short, PKCE replaces the weakness of “a client that cannot securely hold a secret” with “a disposable proof of knowledge valid only between the authorization request and the token exchange.” Unlike a long-lived secret such as client_secret, the code_verifier is discarded after a single authorization flow, so even if a past app secret is exposed through binary analysis, it has no effect on subsequent flows.

Verification: Generating and Checking PKCE’s code_verifier / code_challenge in Python

I implemented, in Python, the generation of code_verifier, the computation of code_challenge, the Authorization Server-side verification logic, and a simulation of the interception attack, and confirmed the behavior by actually running it.

import base64
import hashlib
import os

# 1. The client generates a random code_verifier (RFC 7636: 43-128 characters)
code_verifier_bytes = os.urandom(32)  # 256 bits of entropy
code_verifier = base64.urlsafe_b64encode(code_verifier_bytes).rstrip(b"=").decode()

# 2. Compute code_challenge = BASE64URL(SHA256(code_verifier)) (S256 method)
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()


def verify_pkce(received_verifier: str, expected_challenge: str) -> bool:
    """Authorization Server-side check: recompute code_challenge from code_verifier and compare"""
    recomputed = base64.urlsafe_b64encode(
        hashlib.sha256(received_verifier.encode("ascii")).digest()
    ).rstrip(b"=").decode()
    return recomputed == expected_challenge


# Legitimate client: sends the correct code_verifier
print(verify_pkce(code_verifier, code_challenge))

# Attacker: intercepted the authorization code but does not know code_verifier
attacker_guess = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
print(verify_pkce(attacker_guess, code_challenge))

The actual output was:

code_verifier  = h3G0qbo8Ob4c8GaxpyugQlo7aX3new5OEizwePGkcf8  (43 chars)
code_challenge = pdOVFEzrCrMJJmFNQoJTyU_i_j73Io8wMdtsol3jPsY  (43 chars)

Verification with correct code_verifier: True
Verification with attacker's code_verifier: False

Verification with the correct code_verifier returns True, while an attacker guessing a different random value from the intercepted code_challenge fails verification (False), and the token exchange is rejected. Since the code_verifier’s entropy is 256 bits (2^256 possibilities) and SHA-256’s output space is likewise 2^256, an exhaustive search for a match is confirmed to be computationally infeasible within any realistic timeframe.

In addition to S256 (hashing), code_challenge_method also defines plain (where code_challenge = code_verifier, for backward compatibility), but with plain, eavesdropping on the code_challenge directly exposes the code_verifier, so new implementations should always use S256. Even a confidential client that can securely hold a client_secret (a server-side web app) benefits from adding PKCE, since it provides a second, independent layer of resistance against authorization code interception — which is why the OAuth 2.1 draft is moving toward requiring PKCE for all client types regardless of classification.

Client Credentials Grant

Used for server-to-server (Machine-to-Machine) communication with no user involved.

POST /token
  grant_type=client_credentials
  &client_id=CLIENT_ID
  &client_secret=CLIENT_SECRET
  &scope=api:read

Deprecated Grant Types

  • Implicit Grant: The access token is returned over the front channel (the URL fragment), making it vulnerable to browser history, browser extensions, and malicious in-page JavaScript. It cannot issue refresh tokens since there is no back channel. RFC 9700 explicitly prohibits its use, and the industry has fully consolidated on the authorization code flow with PKCE.
  • Resource Owner Password Credentials: User password passed directly to client, significant security concerns (the client can eavesdrop on or store the password, and it interacts poorly with multi-factor authentication). Slated for removal in OAuth 2.1.

Access Tokens and Refresh Tokens

Access Tokens

Bearer tokens sent via the Authorization header in API requests.

GET /api/resource
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
  • Expiration: Typically short-lived (15 minutes to 1 hour)
  • Scope: Limits accessible resources

Refresh Tokens

Used to obtain new access tokens without re-authentication when the current token expires.

POST /token
  grant_type=refresh_token
  &refresh_token=REFRESH_TOKEN
  &client_id=CLIENT_ID

OpenID Connect (OIDC)

What is OIDC?

OIDC is an authentication layer built on top of OAuth 2.0. While OAuth 2.0 handles “what can be accessed,” OIDC handles “who is the user.”

Adding scope=openid to the authorization request activates the OIDC flow. OIDC also lets the client add a nonce (a random, unique value) to the authorization request. The client generates the nonce, stores it in the session, and includes it in the authorization request. The Authorization Server embeds this value verbatim in the ID Token’s payload, so the client can check, upon receiving the ID Token, whether it matches the value stored in the session (see the verification steps below for details).

ID Token (JWT)

OIDC issues an ID Token in addition to the access token. The ID Token is in JSON Web Token (JWT) format, consisting of three parts:

Header.Payload.Signature

Header:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-id-123"
}

Payload (Standard Claims):

ClaimDescriptionExample
subUnique user identifier"user-123"
issToken issuer"https://auth.example.com"
audTarget client"client-id"
expExpiration (UNIX timestamp)1709942400
iatIssued at1709938800
nonceUnique value specified in the auth request"n-0S6_WzA2Mj"
nameUser name"Taro Yamada"
emailEmail address"taro@example.com"

Signature: Signs Header + Payload with a private key (RSA or ECDSA) or a shared HMAC secret to detect tampering. The signing algorithm is indicated by the Header’s alg, and common values include RS256 (RSA PKCS#1 v1.5), PS256 (RSA-PSS), ES256 (ECDSA P-256), and EdDSA (Ed25519). The mathematical mechanics and security trade-offs of each scheme are covered in Digital Signatures (ECDSA / EdDSA / RSA-PSS): Theory and Python Implementation , and the underlying principles of RSA itself are covered in RSA Encryption: Theory and Python Implementation .

ID Token Verification Steps (a Complete Checklist)

Merely parsing an ID Token is not enough — the client receiving it must perform all of the following checks. Below, each check is mapped to the specific attack it prevents.

  1. Signature verification: Using the Header’s kid (Key ID), select the corresponding public key from the JWKS (JSON Web Key Set, typically fetched from /.well-known/jwks.json) published by the Authorization Server, and verify the signature over Header + Payload.
    • Attack prevented: Payload tampering (e.g., rewriting sub to impersonate a different user). Without a signature, anyone could forge a token with arbitrary contents.
    • Implementation caveat (alg=none attack): The JWT alg header can be freely rewritten by an attacker. If the verifier’s implementation “trusts whatever alg is written in the token,” an attacker can change alg to none, leave the signature portion empty, and slip past signature verification entirely. Preventing this requires the verifier to hard-code the expected algorithm (e.g., RS256) in the verification code and never trust the token’s own alg claim.
  2. iss (issuer) verification: Confirm that the iss claim exactly matches the URL of a known, previously trusted Authorization Server.
    • Attack prevented: Accepting a formally valid but untrustworthy ID Token issued by a different OIDC provider (a malicious Authorization Server, or a domain impersonating the legitimate provider) that an attacker has set up.
  3. aud (audience) verification: Confirm that the aud claim matches the client’s own client_id.
    • Attack prevented: Token substitution/confusion. When the same Authorization Server issues ID Tokens to multiple clients, a malicious Client A could forward an ID Token issued for itself to Client B’s API endpoint, impersonating “a legitimate user of Client B.” Without checking aud, a legitimately signed token would be accepted regardless of who it was actually issued for.
  4. exp / iat verification: Confirm the current time is before exp, and that iat is not an implausibly distant future or past value.
    • Attack prevented: Replay attacks that reuse an old ID Token leaked in the past. Keeping the expiration short limits the window of damage after a leak.
  5. nonce verification: Confirm that the nonce in the ID Token matches the value the client stored in its session at authorization-request time.
    • Attack prevented: ID Token replay attacks. This detects and rejects an attempt by a third party to steal an ID Token legitimately issued in a past authorization flow and resubmit it in a different login session, exploiting the fact that nonce is single-use per flow.

These checks are not an OR condition — all of them are mandatory (an AND condition). Skipping even one leaves you exposed to the corresponding attack.

Verification: Generating and Verifying a JWT, and Rejecting Various Attacks, in Python

Using PyJWT and cryptography, I issued an RS256 ID Token with an RSA key pair and confirmed, for each verification item above, that the normal case succeeds and the attack/anomaly cases are rejected.

import time
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

ISSUER, CLIENT_ID, NONCE = "https://auth.example.com", "client-abc123", "n-0S6_WzA2Mj"
now = int(time.time())
payload = {"iss": ISSUER, "sub": "user-123", "aud": CLIENT_ID,
           "exp": now + 3600, "iat": now, "nonce": NONCE}

id_token = jwt.encode(payload, private_pem, algorithm="RS256", headers={"kid": "key-1"})

# Normal verification
decoded = jwt.decode(id_token, public_pem, algorithms=["RS256"], issuer=ISSUER, audience=CLIENT_ID)
assert decoded["nonce"] == NONCE

The actual results were:

Verification caseResult
Normal token (signature, iss, aud, nonce all match)Verification succeeds
Tampered token (sub rewritten, not re-signed)InvalidSignatureError: Signature verification failed
alg=none token with an empty signature partA vulnerable implementation that leaves algorithms unspecified reads the payload with no verification, but an implementation that explicitly pins algorithms=["RS256"] rejects it with InvalidAlgorithmError: The specified alg value is not allowed
Expired token (exp set 1 hour in the past)ExpiredSignatureError: Signature has expired
Token with iss rewritten to https://evil.example.comInvalidIssuerError: Invalid issuer
Token with aud rewritten to a different client (client-other-999)InvalidAudienceError: Audience doesn't match
Token whose nonce does not match the stored session valueThe application-level nonce comparison detects the mismatch and rejects it

The alg=none case deserves special attention. With an implementation like jwt.decode(none_token, options={"verify_signature": False}) — where the verifier does not specify an algorithm and instead trusts whatever the token declares — I confirmed that a token with no signature at all still had its tampered payload (sub rewritten to admin-000) read back successfully. By contrast, an implementation that explicitly pins algorithms=["RS256"] in the verification code reliably rejected the token’s declared alg: none. This distinction is the single biggest factor separating a safe implementation from a vulnerable one.

UserInfo Endpoint

Additional user information can be retrieved using the access token.

GET /userinfo
Authorization: Bearer ACCESS_TOKEN

Security Considerations

State Parameter (CSRF Protection)

The Login CSRF Attack Scenario

To understand why the state parameter is necessary, it helps to first see how the attack works. Step 3 of the authorization code flow (the callback) completes when the browser, redirected by the Authorization Server, visits REDIRECT_URI?code=AUTH_CODE. Unless the client’s server verifies that “the browser (session) making this request is really the same browser that it earlier had initiate the authorization request,” it cannot distinguish an authorization code that an attacker has planted and delivered via the victim’s browser.

The concrete attack steps (login CSRF) are:

  1. The attacker legitimately starts an authorization flow using their own account and obtains an authorization code, AUTH_CODE_ATTACKER, tied to their own account (without yet performing the token exchange)
  2. The attacker embeds a callback URL containing this code (REDIRECT_URI?code=AUTH_CODE_ATTACKER) into an image tag, an auto-submitting form, or a malicious link, and gets the victim to load it
  3. When the victim’s browser visits that URL, the client’s server — absent state verification — performs the token exchange without suspicion, and binds the victim’s session to “the attacker’s account,” logging the victim in as the attacker
  4. As a result, the victim is now unknowingly logged in as the attacker. If the victim then enters a credit card or other personal information while in this state, that information is saved to the attacker’s account, where the attacker can later view it (this can be more damaging than a typical CSRF, since it can lead to theft of payment details depending on the scenario)

The Defensive Logic Behind the State Parameter

The state parameter is a mechanism for binding two separate HTTP requests — the authorization request and the callback — to the same browser session.

  1. Before sending the authorization request, the client generates a state value from a cryptographically secure random source, stores it in the user’s session (a server-side session or an HttpOnly cookie), and also sends it as a parameter of the authorization request
  2. The Authorization Server passes this state through unchanged and returns it as a query parameter on the callback
  3. Upon receiving the callback, the client checks whether the state in the request matches the state stored in the session of the browser that sent this particular request

In a login CSRF attack, the attacker has no way of knowing “the value of state that should be stored in the victim’s session” (all the attacker knows is the state issued for their own authorization flow). So even if the attacker gets the victim to load a prepared callback URL, it will not match the state stored in the victim’s session, and the client’s server can reject the request. This is exactly the same logical structure as the general CSRF defense of embedding a CSRF token in a form and cross-checking it against the originating session.

Verification: Simulating CSRF Rejection via a State Mismatch

I built a small Python implementation simulating a session store and compared three patterns: (1) the legitimate flow, (2) a login CSRF attack, and (3) a vulnerable implementation that skips state verification.

import secrets

session_store = {}


def start_authorization_request(session_id: str) -> str:
    state = secrets.token_urlsafe(16)
    session_store[session_id] = {"state": state}
    return state


def handle_callback(session_id: str, returned_state: str) -> bool:
    expected_state = session_store.get(session_id, {}).get("state")
    return expected_state is not None and secrets.compare_digest(expected_state, returned_state)

The actual results were:

Scenariostate verification result
Legitimate flow (the issued state comes back unchanged on the callback)True (accepted)
Login CSRF attack (the attacker’s state sent against the victim’s session)False (rejected)
A vulnerable implementation that skips state verificationAlways True (the attack succeeds)

In the legitimate flow, the issued state matched the callback’s state, returning True. When the attacker’s own state (attacker-controlled-state-xyz), obtained from their own flow, was presented against the victim’s session, it did not match the state stored there, returning False and being rejected. I also confirmed that if the state check itself were skipped (as in handle_callback_without_state_check), the mismatch would be ignored and the request always accepted — meaning the login CSRF attack would succeed.

Mandatory PKCE

OAuth 2.1 is expected to require PKCE for all client types. Always use PKCE in new implementations.

Token Storage

Client TypeRecommended StorageNotes
Server-side web appServer sessionReference via HttpOnly Cookie
SPAIn-memory (variable)localStorage is discouraged (XSS risk)
Mobile appOS-provided secure storageKeychain / Keystore

redirect_uri Validation

The Authorization Server must verify exact match with pre-registered redirect_uri. Allowing wildcards or partial matches creates open redirect attack risks.

Summary

AspectOAuth 2.0OpenID Connect
PurposeAuthorization (resource access control)Authentication (user identity verification)
Primary tokenAccess tokenID Token (JWT)
Scopesread, write, etc.openid, profile, email, etc.
Use caseAPI protectionSSO, login

References