MervCodes

Tech Reviews From A Programmer

OAuth 2.0 Implementation for Web Apps

1 min read

OAuth 2.0 is the industry-standard protocol for delegated authorization, and if you've ever clicked "Sign in with Google" you've used it. Yet despite its ubiquity, OAuth remains one of the most misunderstood and misimplemented parts of modern web development. This guide walks through a practical, security-first approach to implementing OAuth 2.0 in a web application, with an emphasis on the patterns that actually hold up in production.

What OAuth 2.0 Actually Solves

OAuth 2.0 is an authorization framework, not an authentication protocol. That distinction trips up a lot of developers. OAuth answers the question "is this application allowed to access this resource on the user's behalf?" — it does not natively answer "who is this user?" For authentication, you layer OpenID Connect (OIDC) on top of OAuth, which adds an ID token.

The core idea is delegation. Instead of handing your application a user's password to a third-party service, OAuth introduces a token exchange. The user authenticates directly with the provider (Google, GitHub, your own auth server), and your app receives a scoped, expiring access token it can use to call APIs. The user's credentials never touch your servers.

There are four main roles to keep straight:

  • Resource Owner — the user who owns the data.
  • Client — your web application.
  • Authorization Server — issues tokens after authenticating the user.
  • Resource Server — the API that accepts tokens and returns protected data.

Choosing the Right Grant Type

OAuth defines several flows ("grants"), and picking the wrong one is one of the most consequential architectural decisions you'll make. For web apps, the guidance is clear:

Authorization Code flow with PKCE is the correct choice for essentially all web applications — both traditional server-rendered apps and single-page applications (SPAs). PKCE (Proof Key for Code Exchange, pronounced "pixie") was originally designed for mobile apps but is now recommended universally.

Avoid these legacy grants:

  • Implicit flow — deprecated. It returned tokens directly in the URL fragment, exposing them to leakage. Do not use it.
  • Resource Owner Password Credentials — has the client collect the user's password directly, defeating the entire purpose of OAuth. Only acceptable for trusted first-party clients, and even then, discouraged.
  • Client Credentials — valid, but only for machine-to-machine communication where no user is involved.

The Authorization Code Flow with PKCE, Step by Step

Here's the sequence your app implements:

  1. Generate a code verifier and challenge. Create a cryptographically random string (the verifier), then hash it with SHA-256 and base64url-encode it (the challenge).
import crypto from 'crypto';

const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
  .createHash('sha256')
  .update(codeVerifier)
  .digest('base64url');
  1. Redirect the user to the authorization server. Include your client ID, requested scopes, a redirect URI, the code challenge, and a state parameter for CSRF protection.
https://accounts.google.com/o/oauth2/v2/auth?
  response_type=code&
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  scope=openid profile email&
  state=RANDOM_STATE&
  code_challenge=CODE_CHALLENGE&
  code_challenge_method=S256
  1. Handle the callback. The user authenticates, approves the scopes, and gets redirected back with an authorization code. Verify the state matches what you stored.

  2. Exchange the code for tokens. Your backend sends the authorization code plus the original code verifier to the token endpoint.

const response = await fetch('https://oauth2.googleapis.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: 'https://yourapp.com/callback',
    client_id: process.env.CLIENT_ID,
    client_secret: process.env.CLIENT_SECRET,
    code_verifier: codeVerifier,
  }),
});

const { access_token, refresh_token, id_token } = await response.json();

The authorization server verifies that the SHA-256 of the code verifier matches the challenge from step 2. This is what prevents an attacker who intercepts the authorization code from redeeming it — they don't have the verifier.

Storing Tokens Securely

Token storage is where many implementations quietly fail. The rules:

Never store tokens in localStorage. It's readable by any JavaScript on the page, which makes it a prime target for XSS attacks. If an attacker can inject a script, they can exfiltrate every token.

The best pattern for web apps is the backend-for-frontend (BFF) approach: your server holds the tokens and stores the session reference in an HttpOnly, Secure, SameSite cookie. The browser never sees the access token at all. Your frontend calls your own backend, and your backend attaches the token when proxying to the resource server.

res.cookie('session', sessionId, {
  httpOnly: true,   // inaccessible to JavaScript
  secure: true,     // HTTPS only
  sameSite: 'lax',  // CSRF mitigation
  maxAge: 3600000,
});

Store the actual access and refresh tokens server-side, keyed by the session — in Redis, a database, or an encrypted session store.

Handling Token Expiry and Refresh

Access tokens are deliberately short-lived, often 15 minutes to an hour. Refresh tokens live longer and let you obtain new access tokens without forcing the user to re-authenticate.

Implement refresh token rotation: each time you use a refresh token, the server issues a new one and invalidates the old. If an old refresh token is ever reused, that signals theft, and you should revoke the entire token family. This limits the damage window of a leaked refresh token dramatically.

Refresh proactively. Rather than waiting for a 401, check the token's expiry and refresh a bit early to avoid a failed request and a retry.

Validating Tokens on the Resource Server

If you're building the API side, validate every incoming token. For JWTs:

  • Verify the signature against the authorization server's public keys (fetched from its JWKS endpoint).
  • Check the iss (issuer), aud (audience), and exp (expiry) claims.
  • Confirm the token carries the scopes required for the requested operation.

Never trust a token just because it's well-formed. A token issued for a different audience or with insufficient scope must be rejected.

Common Pitfalls to Avoid

  • Skipping the state parameter. Without it, you're vulnerable to CSRF on the callback. Always generate, store, and verify it.
  • Overly broad scopes. Request only what you need. Asking for admin access when you need to read a profile erodes user trust and increases blast radius.
  • Exact redirect URI matching. Register precise redirect URIs. Wildcard or loose matching enables open-redirect attacks that steal authorization codes.
  • Logging tokens. Access and refresh tokens are credentials. Keep them out of logs, error trackers, and analytics.

FAQ

Is OAuth 2.0 the same as authentication? No. OAuth 2.0 is an authorization framework. To authenticate users, use OpenID Connect, which builds on OAuth and provides a signed ID token containing verified identity claims.

Do I still need PKCE if my app has a confidential backend with a client secret? Yes, use both. PKCE adds defense-in-depth by protecting the authorization code even in scenarios where the code might be intercepted. Modern best practice recommends PKCE for all client types.

What's the difference between an access token and a refresh token? An access token is presented to APIs to access resources and is short-lived. A refresh token is used only against the authorization server to obtain new access tokens, and lives longer. Treat both as sensitive credentials.

Can I use OAuth for a purely static single-page app with no backend? You can, using Authorization Code flow with PKCE, but you'll be forced to store tokens in the browser, which is riskier. Adding a lightweight backend-for-frontend is strongly recommended so tokens stay server-side in HttpOnly cookies.

How long should tokens live? A common baseline is 15–60 minutes for access tokens and days to weeks for refresh tokens, paired with rotation. Tune based on your security posture — more sensitive applications should use shorter lifetimes.

Should I build my own OAuth server? Almost never. Use a battle-tested identity provider (Auth0, Okta, Keycloak, or your cloud provider's offering). OAuth has subtle security requirements, and a mature provider handles token issuance, rotation, and revocation correctly.

Conclusion

A solid OAuth 2.0 implementation comes down to a few disciplined choices: use the Authorization Code flow with PKCE, keep tokens off the browser with a backend-for-frontend and HttpOnly cookies, rotate refresh tokens, and validate everything on the resource server. Get those fundamentals right and you'll have an authorization layer that's both user-friendly and resistant to the attacks that catch careless implementations.

Sources

Related Articles