Skip to content
Back to Build Tutorials

OAuth 2.0 Flow

Implement the authorization-code flow with PKCE for secure third-party API access.

Why

OAuth is how your app asks for permission to act on a user's behalf in another service (read their GitHub repos, send Slack messages). The authorization-code flow with PKCE is the only secure flow for web and mobile apps — implicit flow is deprecated because it leaks tokens in the URL fragment. PKCE (Proof Key for Code Exchange) prevents an intercepted authorization code from being exchanged by an attacker, even on a public client. Getting this wrong means either your users can't connect their accounts, or worse, an attacker can hijack the OAuth flow and gain access to their data on the third-party service.

When

When your app needs to call a third-party API on behalf of the user. Takes 2-4 h with a library. Skip if you only need SSO (use the auth provider directly).

How

1. Register your app with the provider to get client_id + client_secret
   - Configure redirect URIs for every env (no wildcards)
   - Store secrets in env, never in the client bundle

2. Generate a PKCE code_verifier (43-128 random chars) and code_challenge
   - code_challenge = base64url(sha256(code_verifier)) — S256 method
   - Store the verifier in the session so it's available at the token exchange

3. Redirect the user to the provider's authorize endpoint
   - Include: response_type=code, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method=S256
   - state is a random string you verify on return (prevents CSRF)

4. Handle the redirect back: exchange code + verifier for access_token + refresh_token
   - POST to the token endpoint with grant_type=authorization_code
   - Verify the state matches what you sent; if not, abort

5. Use the access_token to call the API; refresh when it expires
   - Store tokens encrypted in your DB (never in localStorage for web)
   - Refresh proactively at 80% of expiry to avoid mid-request 401s

Example

import { randomBytes, createHash } from "crypto";

// Step 2: PKCE
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
session.set("pkce_verifier", verifier);

// Step 3: redirect to authorize
const authUrl = new URL("https://github.com/login/oauth/authorize");
authUrl.searchParams.set("client_id", env.GH_CLIENT_ID);
authUrl.searchParams.set("redirect_uri", `${env.URL}/api/oauth/callback`);
authUrl.searchParams.set("scope", "repo read:user");
authUrl.searchParams.set("state", session.get("oauth_state"));
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
redirect(authUrl.toString());

// Step 4: exchange code for token
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
  method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/json" },
  body: JSON.stringify({ client_id, client_secret, code, code_verifier: verifier, redirect_uri }),
});

Common Problems

  • ⚠Using the implicit flow (response_type=token) because it's fewer steps — it leaks tokens in the URL bar.
  • ⚠Not verifying the state parameter, so an attacker can initiate the flow and inject their own code.
  • ⚠Storing access tokens in localStorage, where any XSS script can read them — use httpOnly cookies or server sessions.
  • ⚠Forgetting to refresh tokens, so the integration breaks after 1 hour when the access token expires.

Best Practices

  • ✓Always use PKCE, even for server-side apps — it's a 5-line addition that prevents code interception attacks.
  • ✓Request the minimum scope needed and let users see exactly what permissions they're granting.
  • ✓Handle the 'user denied consent' case gracefully — don't error, show a page explaining what they can't do without the connection.
  • ✓Use a library (lucia-auth, oslo, or the provider's SDK) — hand-rolled OAuth has too many footguns to get right.