All posts
authoauth

OAuth 2.0: A Practical Guide for Full-Stack Developers

A practical guide to OAuth 2.0 — the authorization code flow, PKCE, scopes, and the difference between authentication and authorization.

SR

Suhail Roushan

August 6, 2026

·
5 min read
·
0 views

OAuth 2.0 is an authorization protocol, not an authentication protocol, and that one-word distinction explains most of the confusion developers have about what OAuth actually guarantees.

OAuth 2.0 is a protocol that lets a user grant a third-party application limited access to their data on another service — without sharing their password with that third party. "Sign in with Google" uses OAuth under the hood, but OAuth itself only proves the app has permission to access certain data; it's OpenID Connect (built on top of OAuth) that actually standardizes "this is who the user is."

Why OAuth Matters (and When to Skip It)

OAuth solves a real problem: before it existed, "connecting" a third-party app to your Google account meant literally giving that app your Google password. OAuth replaces that with scoped, revocable access tokens — you can see and revoke exactly which apps have access to what, without ever exposing your actual credentials to them.

Skip implementing OAuth yourself unless you're building the authorization server (the "Google" side, not the "sign in with Google" side) — for consuming OAuth as a client, use your framework's or auth library's built-in provider support (Auth.js, Clerk, Better Auth) rather than hand-rolling the flow, which has several security-sensitive steps easy to get wrong.

Getting Started with the OAuth Authorization Code Flow

The standard, most secure flow for web apps:

1. App redirects user to the provider's authorization URL, with client_id, redirect_uri, scope, and state
2. User logs in and approves the requested scopes on the provider's site
3. Provider redirects back to redirect_uri with a one-time authorization code
4. App's backend exchanges that code (plus client_secret) for an access token
5. App uses the access token to call the provider's API on the user's behalf
// step 1: redirect to authorization URL
const authUrl = new URL("https://provider.com/oauth/authorize");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("scope", "read:profile");
authUrl.searchParams.set("state", generateRandomState()); // CSRF protection
authUrl.searchParams.set("response_type", "code");

// step 4: exchange code for a token, server-side
const tokenRes = await fetch("https://provider.com/oauth/token", {
  method: "POST",
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: receivedCode,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    redirect_uri: REDIRECT_URI,
  }),
});

Core OAuth Concepts Every Developer Should Know

The state parameter prevents CSRF attacks on the OAuth flow. Without it, an attacker can trick a user into completing an OAuth flow the attacker initiated, linking the attacker's account to the victim's session. Generate a random value before redirecting, store it (session/cookie), and verify it matches on callback.

PKCE (Proof Key for Code Exchange) is now recommended even for confidential clients, not just mobile/SPA apps it was originally designed for. It adds a cryptographic verifier/challenge pair that prevents authorization code interception attacks:

const codeVerifier = generateRandomString(64);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));
// send codeChallenge in the authorization request
// send codeVerifier in the token exchange request — provider verifies they match

Scopes limit exactly what access is granted. Request the minimum scopes your app actually needs — read:email instead of full account access — both as a security best practice and because users are more likely to approve a narrowly-scoped request.

Access tokens and refresh tokens serve different purposes. The access token is short-lived and used for actual API calls; the refresh token is longer-lived and used only to obtain new access tokens without requiring the user to log in again.

Common OAuth Mistakes and How to Fix Them

Mistake 1: skipping state parameter validation. This is the most common OAuth implementation vulnerability, opening the door to CSRF-style account linking attacks. Fix: always generate, store, and verify state on every OAuth flow, no exceptions.

Mistake 2: doing the token exchange client-side. Exposing client_secret in frontend code defeats the purpose of having a secret at all — anyone can extract it from the JS bundle. Fix: the authorization code → access token exchange must happen server-side, where the secret stays hidden.

Mistake 3: confusing OAuth (authorization) with actual identity verification. OAuth alone tells you "this app has permission to access X," not definitively "this is user Y" — for actual sign-in, you need OpenID Connect's ID token, which is a distinct, verifiable JWT asserting identity, not just an access token.

When Should You Implement OAuth Yourself vs. Use a Library?

Implement it yourself only if you're building the authorization server side (issuing tokens to third-party apps). For consuming OAuth to let users sign in via Google, GitHub, etc., always use your auth library's built-in provider integration — Auth.js, Clerk, Supabase Auth, and Better Auth all handle the code exchange, state validation, and PKCE correctly out of the box, and hand-rolling this flow is a common source of real security vulnerabilities.

OAuth 2.0 in Production

Always use HTTPS for redirect URIs — OAuth's security model assumes it, and providers increasingly reject http:// redirect URIs outside of localhost development. Also register exact redirect URIs with each provider rather than using wildcard patterns where the provider allows it; a loosely-matched redirect URI is a real open-redirect-adjacent vulnerability in OAuth flows.

If you're integrating "Sign in with X" for the fifth time and still hand-writing the authorization code exchange, stop — every major auth library has this solved, and the manual implementation risk isn't worth the marginal control it buys you.

Related posts

Written by Suhail Roushan — Full-stack developer. More posts on AI, Next.js, and building products at suhailroushan.com/blog.

Get in touch