Protocol Guide16 min readApril 16, 2026

OAuth 2.0 Explained: The Complete Guide for Developers

A thorough, practical guide to OAuth 2.0 for developers. Covers all grant types, token formats, PKCE, refresh token rotation, scopes, and common implementation mistakes — with working examples.

KT

KeycloakPro Team

KeycloakPro Team

What OAuth 2.0 Actually Is (and Isn't)

OAuth 2.0 is an authorization framework — a protocol for delegating access to resources. It is not an authentication protocol. It does not tell you who the user is. It tells you what a client application is authorized to do on behalf of a user, or on its own behalf.

This distinction matters because a common OAuth 2.0 mistake is using it directly for login ("login with Google") without layering OpenID Connect (OIDC) on top. OIDC is the identity layer built on OAuth 2.0 that adds user identity — the ID token that tells your application who just logged in. OAuth 2.0 alone only gives you an access token, which is a credential to call an API — not a user identity.

This guide covers OAuth 2.0 thoroughly. OIDC extensions are noted where relevant.


The Core Problem OAuth 2.0 Solves

Before OAuth 2.0, delegating access to third-party apps required sharing your credentials. You've seen the pattern: "Enter your Gmail password so we can import your contacts." The third-party app now has your password — it can do anything Google lets you do.

OAuth 2.0 solves this with delegated authorization using short-lived tokens:

  1. User tells the Authorization Server ("I authorize App X to read my contacts")
  2. Authorization Server issues a limited access token to App X
  3. App X calls Google's API with the token
  4. Google validates the token scope — only contact read access is granted
  5. The user's password was never shared with App X

The user can revoke the token at any time without changing their password. The token is scoped to specific permissions and expires.


OAuth 2.0 Roles

Every OAuth 2.0 flow involves four roles:

Resource Owner: The entity that can grant access to a resource. Usually a user — the person authorizing App X to access their data.

Client: The application requesting access. "Client" is overloaded terminology — in OAuth 2.0 it means the app that wants the access token, not the end-user's browser (though in some flows they're the same machine).

Authorization Server (AS): Issues access tokens after authenticating the Resource Owner and obtaining their authorization. Examples: Google's auth server, a company's Keycloak instance, Auth0.

Resource Server (RS): The API that holds the protected resources. Accepts and validates access tokens. Can be the same server as the Authorization Server or a separate service.


Token Types

Access Token

The credential that authorizes API calls. Presented by the Client to the Resource Server. Has a defined expiry (typically 5 minutes to 1 hour for security-sensitive APIs; up to a few hours for less sensitive ones).

Access tokens can be opaque (a random string the Resource Server validates by calling the Authorization Server) or self-contained JWTs (the Resource Server validates the signature locally using the AS's public key).

JWT access tokens are more common in modern implementations because they allow stateless validation — no AS call needed per request.

Refresh Token

A long-lived credential the Client uses to obtain new access tokens when the current one expires. Refresh tokens are issued by the Authorization Server, not validated by the Resource Server. They're kept secret (never sent to the Resource Server, never in URLs).

Refresh token lifetimes are typically much longer — days to months. The user remains "logged in" without re-authenticating as long as they're active.

ID Token (OIDC only)

An OIDC addition. A JWT that contains claims about the authenticated user — their user ID, email, name. Used by the Client to establish the user's identity. Not used to call APIs. The Client validates the ID token signature using the AS's public key.


Grant Types (Flows)

OAuth 2.0 defines several "grant types" — different patterns for obtaining tokens depending on the client type and use case.

1. Authorization Code Flow

Use case: Web apps, mobile apps, single-page apps — any client with a user present.

This is the primary flow for user-facing applications.

[User] clicks "Login"
       ↓
[Client] redirects user to Authorization Server:
  GET /authorize
    ?response_type=code
    &client_id=myapp
    &redirect_uri=https://myapp.com/callback
    &scope=openid profile email
    &state=random_csrf_token
    
[Authorization Server] authenticates user, shows consent screen
       ↓
[AS] redirects back to client:
  GET https://myapp.com/callback
    ?code=AUTHORIZATION_CODE_HERE
    &state=random_csrf_token
    
[Client] verifies state matches, then exchanges code:
  POST /token
    grant_type=authorization_code
    &code=AUTHORIZATION_CODE_HERE
    &redirect_uri=https://myapp.com/callback
    &client_id=myapp
    &client_secret=mysecret
    
[AS] responds with:
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "dGhpcyBp...",
  "id_token": "eyJhbGci..."
}

Why the two-step dance? The authorization code is short-lived (60 seconds) and single-use. It's exchanged in a server-to-server call (back-channel) rather than in the browser URL. This means the access token never appears in browser history or server logs.

State parameter: A random value the Client generates and verifies on callback. Prevents CSRF attacks — an attacker can't initiate a flow and then forge the callback with a malicious code.

2. Authorization Code Flow with PKCE

Use case: Public clients — native mobile apps, single-page apps that can't keep a client secret secret.

PKCE (Proof Key for Code Exchange, pronounced "pixie") extends the Authorization Code flow for clients that can't store a client secret securely. A mobile app's binary can be reverse-engineered; any secret embedded in it is not secret.

[Client] generates:
  code_verifier = random 32-byte string (base64url encoded)
  code_challenge = SHA256(code_verifier), base64url encoded
  
[Client] redirects:
  GET /authorize
    ?response_type=code
    &client_id=myapp
    &redirect_uri=myapp://callback
    &scope=openid profile
    &state=random_csrf_token
    &code_challenge=BASE64URL_SHA256_OF_VERIFIER
    &code_challenge_method=S256

[User authenticates, AS stores code_challenge with the authorization code]

[Client callback receives code, then:]
  POST /token
    grant_type=authorization_code
    &code=AUTHORIZATION_CODE
    &redirect_uri=myapp://callback
    &client_id=myapp
    &code_verifier=ORIGINAL_VERIFIER  ← The secret that proves ownership
    
[AS] computes SHA256(code_verifier), compares to stored code_challenge
[If match]: issues tokens
[If mismatch]: rejects request

Why this works: The code_verifier never leaves the client over the network. An attacker who intercepts the authorization code can't exchange it for tokens without the original verifier. Even if the AS is compromised during the authorization step, the verifier wasn't transmitted yet.

In 2026: PKCE is recommended for all OAuth 2.0 authorization code flows, including confidential clients (web servers with client secrets). The security benefit is additive — even if your client secret is leaked, PKCE prevents code interception attacks.

3. Client Credentials Flow

Use case: Machine-to-machine (M2M) — no user present. Backend services calling other backend services.

[Service A] authenticates with its own credentials:
  POST /token
    grant_type=client_credentials
    &client_id=service-a
    &client_secret=service-a-secret
    &scope=reports:read data:process
    
[AS] verifies client credentials, issues:
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "reports:read data:process"
}

No user, no user authorization, no refresh token (the service can re-authenticate directly). Access tokens have the scopes the service account is permitted.

This flow maps directly to service accounts in Keycloak — each microservice has its own client ID and secret, and is granted only the scopes it needs (principle of least privilege).

4. Device Authorization Flow

Use case: Devices with limited input capability — smart TVs, IoT devices, CLI tools, game consoles.

[Device] calls:
  POST /device_authorization
    client_id=my-tv-app
    &scope=media:stream
    
[AS] responds:
{
  "device_code": "Ag_EE...",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://example.com/activate",
  "expires_in": 900,
  "interval": 5
}

[Device] shows user: "Go to example.com/activate and enter WDJB-MJHT"

[Device] polls every 5 seconds:
  POST /token
    grant_type=urn:ietf:params:oauth:grant-type:device_code
    &device_code=Ag_EE...
    &client_id=my-tv-app

[User goes to verification_uri on their phone/computer, enters user_code, authenticates]

[Next poll after user completes authorization]:
  AS responds with access_token and refresh_token

The user completes authentication on a different, more capable device. The TV polls until the user completes or the code expires.

5. Implicit Flow (Deprecated — Do Not Use)

The implicit flow was designed for single-page apps before CORS was widely supported. It issued the access token directly in the fragment of the redirect URI (#access_token=...) — visible in browser history.

The implicit flow is deprecated. Use Authorization Code + PKCE for SPAs instead. Modern implementations reject implicit flow clients.


Scopes

Scopes define what access is being requested. They're space-separated strings passed in the scope parameter.

scope=openid profile email read:orders write:orders

Common conventions:

  • openid — required for OIDC (ID token)
  • profile — user's name, picture, locale, etc.
  • email — user's email address
  • offline_access — request a refresh token
  • resource:action — custom pattern (e.g., orders:read, payments:write)

The Authorization Server may grant all requested scopes or a subset. The issued token's scope claim reflects what was actually granted.

For APIs, resource servers define their own scopes. A payment API might define payments:initiate, payments:view, accounts:read. Clients request only what they need. The AS enforces that users can only delegate scopes they have permission to grant.


Tokens in Practice

JWT Structure

A JWT access token has three base64url-encoded parts separated by dots:

HEADER.PAYLOAD.SIGNATURE

Header: Algorithm and token type

{
  "alg": "RS256",
  "kid": "7d8f3a...",
  "typ": "JWT"
}

Payload (claims):

{
  "iss": "https://auth.example.com/realms/myrealm",
  "sub": "user-uuid-here",
  "aud": ["myapi", "account"],
  "exp": 1713300000,
  "iat": 1713299700,
  "jti": "unique-token-id",
  "scope": "openid profile email",
  "preferred_username": "user@example.com",
  "email": "user@example.com",
  "realm_access": {
    "roles": ["user", "reports-viewer"]
  }
}

Signature: The header and payload signed with the AS's private key. The Resource Server verifies the signature using the AS's public key (fetched from the JWKS endpoint).

Validating Access Tokens (Resource Server Side)

A Resource Server must validate every incoming access token:

  1. Fetch the JWKS: Get the AS's public keys from /.well-known/openid-configurationjwks_uri
  2. Verify signature: Cryptographic verification that the token was signed by the AS
  3. Check exp: Reject expired tokens
  4. Check iss: Issuer matches the expected AS
  5. Check aud: Token was issued for this resource server's audience
  6. Check scope: Token includes the scope required for this endpoint

Most SDKs handle steps 1–5 automatically. Step 6 is your application's responsibility.

# Example: FastAPI + python-jose
from jose import jwt, JWTError

KEYCLOAK_JWKS_URI = "https://auth.example.com/realms/myrealm/protocol/openid-connect/certs"
ISSUER = "https://auth.example.com/realms/myrealm"
AUDIENCE = "myapi"

def verify_token(token: str, required_scope: str):
    try:
        payload = jwt.decode(
            token,
            get_jwks(),           # Fetch and cache JWKS
            algorithms=["RS256"],
            audience=AUDIENCE,
            issuer=ISSUER,
        )
        scopes = payload.get("scope", "").split()
        if required_scope not in scopes:
            raise PermissionError(f"Missing scope: {required_scope}")
        return payload
    except JWTError as e:
        raise AuthError(f"Invalid token: {e}")

Refresh Token Rotation

When the access token expires, the client uses the refresh token to get a new one:

POST /token
  grant_type=refresh_token
  &refresh_token=dGhpcyBp...
  &client_id=myapp
  &client_secret=mysecret

The AS issues a new access token and — in rotation mode — a new refresh token. The old refresh token is invalidated.

Refresh token rotation is the current security best practice. If a refresh token is stolen, the attacker uses it once. The next time the legitimate client uses it, the AS detects the conflict (both the original and rotated token being presented) and can invalidate the entire token family.


Token Storage (Client Side)

Where you store tokens depends on the client type.

Server-Side Web Apps

Store tokens in the server session (database, Redis, encrypted cookie). The browser gets only a session cookie. The access token never appears in the browser.

Single-Page Apps (SPAs)

The "where to store tokens in SPAs" question has a nuanced answer:

In-memory only (most secure, least convenient): Store the access token in JavaScript memory. Lost on page refresh — requires re-authentication or a silent refresh (hidden iframe or background auth call).

HttpOnly cookie (practical): The AS sets the access token in an HttpOnly cookie. JavaScript cannot read it; it's sent automatically with requests to the same origin. XSS attacks cannot steal it. Works for same-origin API calls. CORS required for cross-origin APIs.

localStorage (convenient, lower security): Accessible to JavaScript; vulnerable to XSS. Acceptable only when your threat model accepts XSS risk and you have robust XSS prevention elsewhere.

BFF pattern (Backend for Frontend): The SPA makes requests to a same-origin backend server. The backend holds tokens and proxies API calls. The browser never sees the token. Token storage becomes a server-side concern.

Native Mobile Apps

Store in the platform's secure storage:

  • iOS: Keychain
  • Android: EncryptedSharedPreferences or Android Keystore

Never store tokens in plaintext files or shared preferences without encryption.


Common Implementation Mistakes

1. Not validating the state parameter

Skipping state validation leaves your app vulnerable to CSRF attacks. Always generate a random state, store it server-side or in sessionStorage, and verify it on callback before exchanging the code.

2. Using the access token as an authentication proof

An access token proves the client has permission to call an API on behalf of someone. It does not prove who that someone is to your application. For user identity, validate the ID token (OIDC) or call the UserInfo endpoint.

3. Storing access tokens in localStorage

Fine for low-risk applications. Not acceptable for anything handling financial data, health data, or protected personal data. XSS attacks are common; localStorage tokens are trivially exfiltrated.

4. Not checking token expiry before API calls

Call your token store's expiry check before every API call. If expired, refresh first. Don't send expired tokens and handle 401s reactively — the reactive pattern means a failed API call on every session boundary.

5. Long-lived access tokens

Access token lifetimes should be short. 5 minutes for sensitive financial APIs. 15 minutes for typical SaaS APIs. Longer lifetimes increase the window in which a stolen token remains valid. Refresh tokens exist precisely so short-lived access tokens aren't inconvenient.

6. Not rotating refresh tokens

If your AS supports refresh token rotation (Keycloak does), enable it. The security benefit is significant; the implementation change is minimal (store the new refresh token returned with each refresh response).

7. Accepting access tokens without validating the audience

A token issued for API A should not be accepted by API B. Always verify the aud claim. Without audience checking, an access token stolen from one API can be replayed at another.

8. Sending tokens in URL query parameters

Access tokens must not appear in URLs. URL query parameters appear in browser history, server access logs, and referrer headers. Always send tokens in the Authorization: Bearer <token> header.


OAuth 2.0 Security Best Practices (RFC 9700)

The OAuth 2.0 Security Best Current Practice (RFC 9700) consolidates current recommendations:

  • Always use PKCE for authorization code flows, even for confidential clients
  • Bind tokens to the client using mTLS or DPoP (Demonstration of Proof of Possession) for high-security contexts
  • Short access token lifetimes (under 15 minutes for user-facing flows)
  • Exact redirect URI matching — the AS must match the full redirect URI, not just the host
  • No implicit flow — deprecated, removed from new implementations
  • No Resource Owner Password Credentials (ROPC) flow — deprecated; requires the client to handle the user's credentials directly, undermining OAuth's purpose
  • Rotate refresh tokens on every use
  • Sender-constrained tokens for high-value API access — DPoP tokens include a proof of possession that prevents token theft and replay

OAuth 2.0 in Keycloak

Keycloak implements OAuth 2.0 and OpenID Connect fully, plus extensions:

  • FAPI 1.0 and 2.0 profiles for financial-grade API security
  • Token Exchange (RFC 8693) — exchange one token for another (impersonation, delegation)
  • Device Authorization Flow built-in
  • UMA 2.0 (User-Managed Access) for fine-grained authorization
  • Client policies for enforcing security requirements (PKCE required, specific token lifetimes) per client or globally
  • JWKS endpoint at {realm-url}/protocol/openid-connect/certs — all tokens are RS256 signed with realm keys

Every OAuth 2.0 grant type described in this guide is implemented in Keycloak out of the box. Custom grant types and authentication flows are possible via the SPI.


Practical Next Steps

Understand the token you're receiving: Paste an access token at jwt.io (never do this in production — it's a debugging tool for development only). See the claims, the issuer, the expiry.

Set up a local Keycloak: See our Keycloak Docker Compose guide — 10 minutes to a working local OAuth 2.0 authorization server.

Implement the Authorization Code + PKCE flow: See our Keycloak OAuth 2.0 PKCE guide for a complete implementation walkthrough.

Read RFC 6749: The original OAuth 2.0 specification is surprisingly readable. If you're implementing OAuth 2.0 in a non-standard context, the spec is the authoritative source.


Summary

Grant TypeUse CaseRequires User?Refresh Token?
Authorization Code + PKCEWeb apps, mobile apps, SPAsYesYes
Authorization Code (confidential)Server-side web appsYesYes
Client CredentialsM2M, backend servicesNoNo
Device AuthorizationTV, CLI, IoTYes (on another device)Yes
ImplicitDeprecated — don't use
ROPCDeprecated — don't use

OAuth 2.0 is a delegation protocol. OIDC adds identity. PKCE adds security for public clients. JWT access tokens enable stateless API authorization. Refresh tokens enable long-lived sessions without password re-entry. Together, these primitives cover almost every authentication and authorization pattern your application will need.

Questions about implementing OAuth 2.0 in your stack? Talk to the KeycloakPro team →

Need Help With Keycloak?

Our team specializes in production-grade Keycloak deployments. Get a free 30-minute strategy consultation.

Book a Free Strategy Call