Safeguard
Application Security

How to implement OAuth 2.0 securely

A step-by-step guide to implementing OAuth 2.0 securely: PKCE, redirect URI validation, token storage, and the vulnerabilities to avoid.

Aman Khan
AppSec Engineer
7 min read

If your team is wiring up single sign-on, a public API, or a mobile app, you will eventually reach the point where you need to implement OAuth 2.0 securely, and that is where most integrations quietly go wrong. OAuth is deceptively simple to get running and surprisingly easy to get wrong: an open redirect URI, a leaked authorization code, or a client secret embedded in a mobile app can turn a delegated-authorization protocol into an attacker's shortcut to account takeover. This guide walks through a practical, sequential setup for implementing OAuth 2.0 securely in a production application, covering the authorization code flow with PKCE, redirect URI validation, token issuance and storage, and scope enforcement. By the end, you will have a checklist you can apply to any client type, whether a single-page app, mobile app, or server-side service, along with verification steps to confirm your setup holds up against real-world attacks.

Step 1: Choose the Right Grant Type to Implement OAuth 2.0 Securely

The single most common OAuth mistake happens before the first line of code is written: picking the wrong grant type. The implicit grant is deprecated and should not be used in new implementations — it returns access tokens directly in the URL fragment, where they can leak through browser history, referrer headers, and logs. The resource owner password credentials grant should also be avoided outside of legacy migrations, since it requires your application to handle raw user passwords.

For nearly every modern use case — single-page apps, mobile apps, and traditional server-rendered apps alike — the answer is the authorization code flow with PKCE (Proof Key for Code Exchange). This is the core of current OAuth 2.0 best practices, as codified in IETF's OAuth 2.0 Security Best Current Practice (RFC 9700), which now recommends PKCE for all clients, not just public ones without a client secret.

Step 2: Register Your Client with Strict Redirect URIs

When registering your OAuth client with the authorization server, resist the temptation to use wildcard or pattern-matched redirect URIs. Loose redirect URI matching is one of the most exploited OAuth security vulnerabilities to avoid, since an attacker who can redirect the authorization response to a domain they control can steal authorization codes or tokens.

Register exact, fully-qualified redirect URIs:

# Good: exact match, no wildcards
https://app.example.com/auth/callback

# Bad: allows subdomain takeover or open redirect abuse
https://*.example.com/auth/callback
https://app.example.com/auth/*

If you support multiple environments, register a separate redirect URI per environment (dev, staging, production) rather than relying on a single permissive pattern, and have your authorization server reject any request whose redirect_uri doesn't match a registered value byte-for-byte.

Step 3: Implement the PKCE Oauth Flow Explained Step by Step

PKCE closes the gap that made the authorization code flow vulnerable to interception, particularly on mobile and single-page apps where the client can't hold a secret safely. Here's the PKCE oauth flow explained in the order your code should execute it:

  1. Generate a cryptographically random code_verifier (43-128 characters).
  2. Derive a code_challenge by hashing the verifier with SHA-256 and base64url-encoding it.
  3. Send the code_challenge with the initial authorization request.
  4. Send the original code_verifier when exchanging the authorization code for a token.
// 1 & 2: generate verifier and challenge (browser example)
function base64UrlEncode(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
const codeChallenge = base64UrlEncode(digest);

sessionStorage.setItem('pkce_verifier', codeVerifier);
# 3: authorization request
GET /authorize?
  response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://app.example.com/auth/callback
  &scope=openid%20profile%20orders:read
  &state=RANDOM_UNGUESSABLE_VALUE
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
# 4: token exchange
POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE_FROM_CALLBACK
&redirect_uri=https://app.example.com/auth/callback
&client_id=YOUR_CLIENT_ID
&code_verifier=ORIGINAL_CODE_VERIFIER

Because the token exchange requires the original, unhashed verifier, an attacker who intercepts only the authorization code cannot complete the exchange without also compromising the client that generated the verifier.

Step 4: Protect the state Parameter Against CSRF

PKCE protects the code exchange, but it does not protect against cross-site request forgery on the callback itself. Always generate a random, unguessable state value per authorization request, store it server-side or in a short-lived cookie bound to the user's session, and verify it matches exactly when the callback returns:

import secrets

state = secrets.token_urlsafe(32)
session["oauth_state"] = state

# on callback:
if request.args.get("state") != session.pop("oauth_state", None):
    abort(400, "State mismatch — possible CSRF attempt")

Skipping state validation is a recurring finding in OAuth implementation reviews: without it, an attacker can trick a victim into completing an authorization flow initiated by the attacker, binding the victim's session to the attacker's account.

Step 5: Issue Short-Lived Access Tokens and Rotate Refresh Tokens

Once you've exchanged the code, treat the tokens you receive as high-value secrets:

  • Keep access token lifetimes short (5–15 minutes) so a leaked token has a narrow window of usefulness.
  • Use refresh token rotation: issue a new refresh token on every use and invalidate the previous one, so a replayed old refresh token immediately signals theft.
  • Store tokens in httpOnly, Secure, SameSite=Strict cookies for browser-based apps rather than localStorage, which is readable by any script and therefore exposed to XSS.
  • For native and mobile apps, use platform-provided secure storage (Keychain on iOS, Keystore on Android) rather than plain files or shared preferences.
Set-Cookie: refresh_token=abc123; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh

Step 6: Scope Tokens Tightly and Validate Them on Every Request

Request only the scopes your application actually needs, and have your resource servers enforce those scopes independently rather than trusting the client's claims. On the API side, validate the token's signature, expiration, audience (aud), and issuer (iss) on every request — not just at login:

const { payload } = await jwtVerify(token, jwks, {
  issuer: 'https://auth.example.com/',
  audience: 'https://api.example.com',
});

if (!payload.scope?.split(' ').includes('orders:read')) {
  throw new Error('Insufficient scope');
}

Overly broad scopes turn a single compromised token into a much bigger breach than it needs to be, and audience validation prevents a token issued for one API from being replayed against another.

Troubleshooting and Verification

Once your flow is running end to end, verify it deliberately rather than assuming it's correct because the happy path works:

  • Redirect URI test: attempt authorization with a slightly modified redirect URI (extra trailing slash, different port, subdomain). The authorization server should reject it outright.
  • State replay test: submit a callback with a missing or stale state value and confirm your app rejects it with a 400, not a silent fallback.
  • PKCE downgrade test: try completing a token exchange with a code_verifier that doesn't match the original code_challenge. If the token endpoint issues a token anyway, PKCE isn't actually being enforced server-side.
  • Token replay test: capture an access token from a login and try using it after it should have expired, and try using a rotated-out refresh token. Both should fail immediately.
  • Scope escalation test: request a token with limited scopes and confirm the resource server rejects calls to endpoints outside that scope, rather than only checking scope on the client.
  • Open redirect check: confirm the redirect_uri on error responses (e.g., error=access_denied) is also validated, since some implementations only check it on success.

If any of these checks pass when they should fail, you've found one of the OAuth security vulnerabilities to avoid before an attacker does — treat each as a blocking issue, not a backlog item.

How Safeguard Helps

Getting the OAuth flow right on paper is one thing; keeping it right as your codebase, dependencies, and CI/CD pipeline evolve is another. Safeguard's software supply chain security platform continuously scans your repositories and build pipelines for the patterns that lead to insecure OAuth deployments — hardcoded client secrets committed to source control, dependencies with known token-handling CVEs, misconfigured redirect allowlists checked into infrastructure-as-code, and CI jobs that leak tokens into build logs.

Rather than relying on a one-time security review, Safeguard integrates into your SCM and build process to flag secret exposure and vulnerable auth libraries automatically, before they ship, and to give your AppSec team a single view of authentication-related risk across every service that implements OAuth. That means the checklist in this guide doesn't just get applied once during implementation — it gets continuously enforced as your team ships new services and updates dependencies, so a secure OAuth setup stays secure.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.