OAuth 2.0 Flows Explained: SPA Authentication & Beyond

Christopher Espiritu

Christopher Espiritu

OAuth 2.0 Flows Explained: How SPAs Authenticate Securely

If you've ever wondered how your favorite web apps handle login without exposing your credentials everywhere, OAuth 2.0 is the answer. But not all OAuth flows are created equal—and choosing the wrong one can leave your application vulnerable.

This guide breaks down the most common OAuth 2.0 flows, with a focus on the unique challenges Single Page Applications face and how to solve them properly.


Why SPAs Are Different

Single Page Applications present a fundamental security problem: they're public clients.

Everything in an SPA runs in the user's browser. That means any "secret" you embed in your JavaScript—including an OAuth client_secret—can be viewed by anyone who opens DevTools. There's no hiding it.

Compare this to a traditional server-side application (a "confidential client") where secrets live safely on your backend, never exposed to the browser.

This distinction drives which OAuth flow you should use.

Client Type Can Store Secrets? Examples
Public No SPAs, mobile apps, desktop apps
Confidential Yes Server-side web apps, backend services

The Right Choice for SPAs: Authorization Code with PKCE

The Authorization Code Grant with PKCE (Proof Key for Code Exchange) is the modern standard for public clients. It's what you should use for SPAs and mobile apps.

How PKCE Works

PKCE adds a clever handshake that proves the token request comes from the same client that initiated the login—without needing a stored secret.

Here's the idea:

  1. Your SPA generates a random code_verifier (a high-entropy string)
  2. It creates a code_challenge by hashing the verifier (SHA256)
  3. The challenge goes to the authorization server at login start
  4. The verifier goes to the token endpoint when exchanging the code
  5. The server verifies they match

Even if an attacker intercepts the authorization code, they can't exchange it for tokens without the original code_verifier.

The Flow

sequenceDiagram
    participant User
    participant SPA as SPA (Browser)
    participant AS as Auth Server
    participant API as Resource Server

    Note over SPA: Generate code_verifier & code_challenge

    User->>SPA: Click "Login"
    SPA->>AS: Redirect to /authorize<br/>(client_id, redirect_uri, code_challenge)
    
    Note over AS: User authenticates & consents
    
    AS->>SPA: Redirect to callback with authorization code
    
    Note over SPA: Verify state parameter
    
    SPA->>AS: POST /token<br/>(code, code_verifier)
    
    Note over AS: Verify code_verifier matches code_challenge
    
    AS-->>SPA: access_token, refresh_token
    
    Note over SPA: Store tokens (memory or sessionStorage)
    
    SPA->>API: Request with Bearer token
    API-->>SPA: Protected resource

Key Parameters

Parameter Purpose
code_verifier Random string (43-128 chars), kept secret in the SPA
code_challenge Base64URL-encoded SHA256 hash of the verifier
code_challenge_method Always use S256 (SHA256)
state Random value to prevent CSRF attacks

Other OAuth 2.0 Flows

Authorization Code (Standard)

The original authorization code flow—designed for confidential clients that can safely store a client_secret.

Use case: Server-side web applications. Think Laravel with Socialite, Rails with OmniAuth, or any traditional MVC app.

sequenceDiagram
    participant User
    participant Browser
    participant Server as Your Server
    participant AS as Auth Server

    Browser->>Server: Initiate login
    Server->>AS: Redirect to /authorize
    
    Note over AS: User authenticates
    
    AS->>Browser: Redirect with code
    Browser->>Server: Callback with code
    Server->>AS: POST /token<br/>(code + client_secret)
    AS-->>Server: Tokens
    
    Note over Server: Create session
    
    Server-->>Browser: Logged in

The key difference: the token exchange happens server-to-server, where the client_secret stays safe.


Client Credentials

For machine-to-machine communication where no user is involved.

Use case: Your backend service needs to call another internal API. A cron job needs to access protected resources. Microservices authenticating with each other.

sequenceDiagram
    participant Service as Backend Service
    participant AS as Auth Server
    participant API as Target API

    Service->>AS: POST /token<br/>(client_id + client_secret)
    AS-->>Service: access_token
    Service->>API: Request with Bearer token
    API-->>Service: Response

Simple and direct—the service authenticates as itself, not on behalf of any user.


Deprecated Flows (Avoid These)

Implicit Grant ⚠️

The implicit flow was the original solution for SPAs before PKCE existed. Don't use it anymore.

Problems:

  • Access tokens appear directly in the URL fragment
  • Tokens can leak through browser history and referrer headers
  • No refresh tokens supported
  • Vulnerable to token interception

Resource Owner Password Credentials (ROPC) ⚠️

This flow lets your app collect the user's username and password directly. Avoid it.

Problems:

  • Trains users to enter credentials outside the auth server (phishing risk)
  • Bypasses MFA and other security features
  • Your app handles raw credentials—massive liability

The only exception: migrating legacy systems where you control both the client and the auth server, and you're actively working toward a better solution.


Quick Reference

Flow Client Type Use Case Security
Auth Code + PKCE Public SPAs, mobile apps ✅ Recommended
Auth Code (Standard) Confidential Server-side apps ✅ Recommended
Client Credentials Confidential M2M / service accounts ✅ Recommended
Implicit Public ❌ Deprecated
ROPC Any ❌ Deprecated

Implementation Tips

For SPAs using PKCE:

  • Store tokens in memory when possible (most secure)
  • If persistence is needed, use sessionStorage over localStorage
  • Always validate the state parameter on callback
  • Use short-lived access tokens with refresh token rotation
  • Consider a Backend-for-Frontend (BFF) pattern for sensitive applications

For Laravel Passport specifically:

  • Enable PKCE support when creating your client
  • Use the --public flag: php artisan passport:client --public
  • Configure appropriate token lifetimes in AuthServiceProvider

Wrapping Up

OAuth 2.0 isn't one-size-fits-all. The right flow depends on where your code runs and whether it can keep secrets.

For SPAs and mobile apps: Authorization Code with PKCE. No exceptions.

For server-side apps: Standard Authorization Code with a properly protected client_secret.

For service-to-service: Client Credentials.

Everything else is either deprecated or serves a narrow legacy use case. When in doubt, PKCE is your friend.