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:
- Your SPA generates a random
code_verifier(a high-entropy string) - It creates a
code_challengeby hashing the verifier (SHA256) - The challenge goes to the authorization server at login start
- The verifier goes to the token endpoint when exchanging the code
- 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
sessionStorageoverlocalStorage - Always validate the
stateparameter 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
--publicflag: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.