T
DeveloperOAuthSecurityAuthentication

OAuth 2.0 Explained for Developers (2026)

OAuth 2.0 demystified — flows, tokens, and best practices.

TechHowAI Team8 min read

OAuth 2.0 is an authorization framework, not an authentication protocol — though teams constantly use it for login. Understanding that distinction prevents the most common implementation bugs: treating an access token as proof of identity, storing refresh tokens in localStorage, or skipping PKCE on public clients. This guide explains the actors, flows, tokens, and hardening steps you need when wiring "Sign in with Google" or building your own authorization server.

Authorization vs authentication

  • Authentication answers: Who is this user?
  • Authorization answers: What is this client allowed to do on the user's behalf?

OAuth 2.0 defines how a client obtains limited access to a resource server after the resource owner (usually the user) approves via an authorization server. OpenID Connect (OIDC) sits on top of OAuth and adds ID tokens for authentication — if you need profile info and a stable sub claim, use OIDC, not raw OAuth alone.

The four roles

RoleExample
Resource ownerUser with a Gmail account
ClientYour SaaS web app or mobile app
Authorization serverGoogle Accounts, Auth0, Keycloak
Resource serverGmail API, your own API

Tokens you will encounter

Access tokens are presented to APIs:

http
GET /v1/profile HTTP/1.1
Host: api.example.com
Authorization: Bearer ya29.a0AfH6SMBx...

They are opaque strings or JWTs. Short lifetimes (5–60 minutes) limit exposure.

Refresh tokens are long-lived secrets used only against the token endpoint to obtain new access tokens. Store them server-side or in secure platform storage (iOS Keychain, Android Keystore) — never in browser localStorage.

Authorization codes are one-time, short-lived codes exchanged at the token endpoint. They are useless without the client secret (confidential clients) or PKCE verifier (public clients).

Decode JWT access tokens during development with /tools/jwt-decoder to verify iss, aud, exp, and custom scopes — but remember: never trust JWT contents without signature verification.

Scopes express least privilege

Scopes are space-delimited strings:

scope=read:projects write:projects offline_access

Request the minimum scope set. Users hesitate when an app asks for "full account access" to display a profile avatar. Document each scope in your developer portal and map scopes to backend permission checks — a scope string in a token means nothing if your API ignores it.

Authorization Code flow (the default)

Use this for server-side web apps and native apps with PKCE. It keeps secrets off the user agent.

Step 1 — Redirect user to authorize:

http
GET https://auth.example.com/oauth/authorize?
  response_type=code&
  client_id=app_123&
  redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
  scope=read%3Aprojects&
  state=random_csrf_token&
  code_challenge=E9Melhoa2OwvFrEMTJguCHAOqPnybwJwgKluWzG1bE8&
  code_challenge_method=S256 HTTP/1.1

state prevents CSRF: generate random bytes, store in session, validate on callback.

Step 2 — User approves; browser returns:

http
GET /callback?code=SplxlOBeZQQYbYS6WxSbIA&state=random_csrf_token HTTP/1.1

Step 3 — Backend exchanges code for tokens:

http
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
client_id=app_123&
client_secret=shhh&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Step 4 — Token response:

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "def50200a1b2c3...",
  "scope": "read:projects"
}

Implement this exchange on your backend only. The client secret must never ship in a SPA bundle.

PKCE for public clients

Single-page apps and mobile apps cannot hold a client secret. PKCE (Proof Key for Code Exchange) binds the authorization code to a verifier generated at request time:

javascript
// Browser — use Web Crypto API
const verifier = crypto.randomUUID() + crypto.randomUUID();
const digest = await crypto.subtle.digest(
  'SHA-256',
  new TextEncoder().encode(verifier)
);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

sessionStorage.setItem('pkce_verifier', verifier);
// Send `challenge` in authorize URL; send `verifier` at token exchange

OAuth 2.1 consolidates best practices: public clients must use PKCE; implicit flow is deprecated.

Client Credentials flow

Machine-to-machine services use client ID + secret directly — no user involved:

http
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=reports:read&client_id=svc_456&client_secret=...

Rotate service secrets through your secrets manager. Scope these tokens narrowly; they often bypass user-level audit trails.

Flows to avoid in new projects

Implicit flow (response_type=token) exposed access tokens in URL fragments. Do not build new integrations with it.

Resource Owner Password Credentials (username/password posted to token endpoint) bypasses the authorization server's consent UI. Only acceptable for migrating legacy first-party apps — and even then, prefer Authorization Code.

Device Authorization flow suits TVs and CLI tools: user visits a URL, enters a code, device polls until authorized. Useful pattern when no browser redirect is available.

Refresh token rotation

When an access token expires, exchange the refresh token:

http
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=def50200...&client_id=app_123

Enable refresh token rotation: each use issues a new refresh token and invalidates the old one. Detect reuse (two requests with the same token) and revoke the entire token family — that pattern catches stolen refresh tokens.

Securing token endpoints

Hash client secrets at rest with a slow algorithm (Argon2, bcrypt). Compare using constant-time functions via /tools/hash-generator test vectors during implementation reviews.

Rate-limit /oauth/token and /oauth/authorize to slow brute force and credential stuffing.

Log authorization events (client_id, user_id, scopes, IP) without logging raw tokens or secrets.

Bind tokens to sender when feasible (cnf claim in JWTs for mTLS or DPoP).

Validating tokens in your API

For JWT access tokens:

javascript
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  });
  if (!payload.scope?.includes('read:projects')) {
    throw new Error('insufficient_scope');
  }
  return payload;
}

Check exp with clock skew tolerance (±60 seconds). Reject none algorithm tokens. For opaque tokens, call the authorization server's introspection endpoint:

http
POST /oauth/introspect HTTP/1.1
Content-Type: application/x-www-form-urlencoded

token=ya29...&client_id=app_123&client_secret=shhh

OAuth implementation checklist

  • Authorization Code + PKCE for browser and mobile clients
  • state parameter validated on every callback
  • Redirect URI allowlist enforced (exact match, no wildcards in production)
  • Refresh tokens stored securely and rotated
  • Scopes enforced at the resource server, not just printed in docs
  • Token lifetimes documented; revocation endpoint available
  • Client secrets hashed; no secrets in frontend bundles
  • JWT signatures verified against issuer JWKS (if using JWT access tokens)

Revocation and logout

OAuth defines token revocation:

http
POST /oauth/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded

token=def50200...&token_type_hint=refresh_token&client_id=app_123&client_secret=shhh

Call revocation when users change passwords, disable accounts, or click "Sign out everywhere." Access tokens may remain valid until expiry unless you maintain a denylist — short access token TTL (15 minutes or less) shrinks that window.

For federated logout, OIDC adds end_session_endpoint to clear the authorization server session; without it, users re-authenticate silently on next login because the IdP cookie persists.

Registering clients safely

Dynamic client registration (RFC 7591) lets apps self-register — useful for marketplaces, dangerous without approval workflows. For first-party apps, register clients manually:

SettingRecommendation
redirect_urisExact HTTPS URLs only; no http://localhost in production
grant_typesauthorization_code only for user-facing apps
response_typescode only — disable token
token_endpoint_auth_methodclient_secret_post or private_key_jwt for confidential clients

Store client_id in frontend config (public) but never client_secret. Rotate secrets by issuing a second secret, migrating backends, then revoking the old one.

Common integration failures

Redirect URI mismatchhttps://app.com/callback vs https://app.com/callback/ fails. Normalize trailing slashes in docs and tests.

Clock skew — JWT exp validation rejects tokens when server clocks drift. Sync NTP on all nodes.

Scope downgrade ignored — client requests read write but token returns read only; verify the granted scope string, not what you asked for.

Mixing authentication protocols — using access tokens where ID tokens belong conflates API access with login proof. OIDC ID tokens go to the session layer; access tokens go to API calls.

Debugging tips

When integrations fail, compare the authorize URL parameters character by character — encoding mistakes in redirect_uri cause silent failures.

Use /tools/jwt-generator to craft test tokens with near-expiry exp claims and verify your API rejects them.

If users report "logged in as wrong account," check whether you keyed sessions on email instead of the stable sub claim from OIDC.

Conclusion

OAuth 2.0 delegates access without sharing passwords. Implement Authorization Code with PKCE, treat scopes as enforceable permissions, and keep refresh tokens out of untrusted storage. Layer OpenID Connect when you need authenticated identity, not just API access.

Next steps: Read the JWT complete guide for ID token structure, inspect real tokens with /tools/jwt-decoder, and add automated tests that walk through the full authorize → callback → token exchange path in CI.

Put what you learned into practice — no signup required.