I once built a login system by storing passwords in a database column called password_plain. In production. For a client. I was 19 and didn't know better. Ten years later, I still think about it at night.
That app is dead now (thankfully), but the problem it tried to solve — "let users log in safely" — is still the thing most developers get wrong. Not because they store plaintext passwords anymore. Because they build authentication from scratch when they should be using OAuth 2.0. Or they use OAuth 2.0 without understanding what it actually does, and end up with security holes that make plaintext passwords look quaint.
The identity and access management market hit $22.27 billion in 2025 and is growing at 15% annually. Over 14,268 companies use OAuth as their identity management tool. It's everywhere. And most explanations of how it works are either oversimplified ("it's like a hotel key card!") or incomprehensible (RFC 6749 is 76 pages long).
I'm going to explain OAuth 2.0 the way I wish someone had explained it to me: by building it from scratch, step by step, until you understand not just what happens but why each piece exists.
The Problem OAuth Solves (It's Not Login)
Here's the first thing most tutorials get wrong: OAuth is not an authentication protocol. It's an authorization protocol.
Authentication = "Who are you?"
Authorization = "What are you allowed to do?"
OAuth answers the second question. It was designed so that a third-party app could access your data on another service without you giving it your password.
Think about it. When you click "Sign in with Google" on some random SaaS app, you're not giving that app your Google password. You're telling Google: "Hey, let this app see my email address and profile picture. Nothing else."
That's authorization. The app gets a limited token — not your credentials.
Why does this matter? Because 78% of people reuse passwords across multiple sites. If you give App X your Google password and App X gets hacked, the attacker now has your Google password too. OAuth eliminates this entire category of risk.
Verizon's 2025 Data Breach Investigations Report found that 22% of breaches began with stolen credentials — higher than any other category. OAuth exists because sharing passwords between services was (and is) a terrible idea.
The Four Players
Every OAuth 2.0 flow has four roles. Understanding these is 80% of understanding OAuth.
| Role | What It Is | Example |
|---|
| Resource Owner | The user who owns the data | You |
| Client | The app that wants access | A to-do app wanting your Google Calendar |
| Authorization Server | Issues tokens after user consents | Google's OAuth server |
| Resource Server | Holds the protected data | Google Calendar API |
Sometimes the Authorization Server and Resource Server are the same system (common for smaller services). Sometimes they're separate (Google has distinct auth servers and API servers).
The Resource Owner (you) tells the Authorization Server (Google): "Yes, this Client (to-do app) can read my calendar." The Authorization Server issues an access token. The Client uses that token to call the Resource Server (Calendar API).
No password was shared. The to-do app never sees your Google credentials. It only gets a token with limited permissions (called scopes) that expires after a set time.
Scopes: The Permission System
Scopes are how OAuth implements the principle of least privilege. Instead of giving an app full access to your account, you grant specific, narrow permissions.
Google, for example, defines scopes like:
openid — verify the user's identity
email — read the user's email address
profile — read the user's name and picture
https://www.googleapis.com/auth/calendar.readonly — read (but not write) calendar events
https://www.googleapis.com/auth/calendar — read AND write calendar events
A to-do app that only needs your email for login should request openid email. If it's asking for full calendar write access, that's a red flag.
Scopes are visible to the user on the consent screen. They're also enforced by the resource server — if a token was issued with calendar.readonly, any attempt to create or delete events will be rejected, even if the access token is valid.
This is fundamentally different from password-based auth, where the third-party app gets full access to everything in your account. Scopes are the reason OAuth works as a trust model.
Building It From Scratch: The Authorization Code Flow
The Authorization Code Flow is the most commonly used OAuth 2.0 flow and the only one you should use for user-facing web applications in 2026. Let me walk through every step.
Step 1: Register your app
Before anything happens, you register your Client with the Authorization Server. You get back two things:
- Client ID — a public identifier for your app
- Client Secret — a secret known only to your app and the auth server (never expose this in frontend code)
You also register one or more redirect URIs — the URLs the auth server will send users back to after they authorize.
Step 2: Redirect the user
When a user clicks "Sign in with Google" in your app, you redirect them to the Authorization Server with specific parameters:
https://accounts.google.com/o/oauth2/v2/auth?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid%20email%20profile
&state=random_csrf_token_abc123
Let me break down each parameter:
response_type=code — "I want an authorization code" (not a token directly)
client_id — identifies your app
redirect_uri — where to send the user after they approve
scope — what permissions you're requesting (openid email profile means "basic identity info")
state — a random value you generate to prevent CSRF attacks (more on this later)
Step 3: User gives consent
Google shows the user a consent screen: "This app wants to see your email and profile. Allow?" The user clicks "Allow."
This is the only time the user interacts with the Authorization Server. They never type their password into your app. They type it into Google's login page, on Google's domain.
Step 4: Authorization code comes back
The Authorization Server redirects the user back to your app with an authorization code:
https://yourapp.com/callback?code=AUTH_CODE_xyz789&state=random_csrf_token_abc123
Two things you must do here:
- Verify the
state parameter matches what you sent in Step 2. If it doesn't, someone is trying a CSRF attack. Reject the request.
- Use the authorization code quickly. It expires in minutes (usually 10). It's a one-time-use code.
Step 5: Exchange the code for tokens
Your backend server (never the frontend) makes a POST request to the Authorization Server's 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: 'AUTH_CODE_xyz789',
redirect_uri: 'https://yourapp.com/callback',
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET',
}),
})
const tokens = await response.json()
// {
// access_token: "ya29.a0AfH6SM...",
// refresh_token: "1//0gdN...",
// expires_in: 3600,
// token_type: "Bearer",
// id_token: "eyJhbGciOi..."
// }
Notice: the client_secret is sent server-side. It never touches the browser.
Step 6: Use the access token
Now your app can call the Resource Server using the access token:
const profile = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: 'Bearer ya29.a0AfH6SM...',
},
})
const user = await profile.json()
// { id: "12345", email: "user@gmail.com", name: "Ismat" }
The access token expires (usually in 1 hour). When it does, you use the refresh token to get a new one — without making the user log in again.
Step 7: Refresh when expired
Access tokens are deliberately short-lived. If one gets stolen, the damage window is small. When the token expires, your backend exchanges the refresh token for a new access token:
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: 'YOUR_STORED_REFRESH_TOKEN',
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET',
}),
})
// Returns a new access_token (and sometimes a new refresh_token)
Store refresh tokens securely on your server. Never send them to the frontend. Treat them like passwords — because anyone who has the refresh token can generate new access tokens indefinitely.
That's the full flow. Seven steps. No passwords shared. Limited, time-bound access.
PKCE: The Extension You Must Use
Here's a problem with the basic Authorization Code Flow: what if an attacker intercepts the authorization code in Step 4? On mobile apps or single-page apps, this is a real risk — there's no client secret to protect the code exchange.
PKCE (Proof Key for Code Exchange, pronounced "pixie") solves this. It's required in OAuth 2.1 for all clients, not just public ones.
Here's how it works:
Before Step 2, your app generates a random string called a code verifier:
import crypto from 'crypto'
// Generate a random 43-128 character string
const codeVerifier = crypto.randomBytes(32).toString('base64url')
// Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
Then you hash it to create a code challenge:
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url')
// Example: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
In Step 2, you send the code challenge (not the verifier) with the authorization request:
https://accounts.google.com/o/oauth2/v2/auth?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid%20email%20profile
&state=random_csrf_token_abc123
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
In Step 5, you send the original code verifier with the token exchange:
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: 'AUTH_CODE_xyz789',
redirect_uri: 'https://yourapp.com/callback',
client_id: 'YOUR_CLIENT_ID',
code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
}),
})
The auth server hashes the verifier and compares it to the challenge it received earlier. If they match, the request is legitimate. If an attacker intercepted the authorization code, they can't exchange it without the original verifier.
It's a simple, elegant solution. And it's no longer optional.
The Other Grant Types (And When to Use Each)
The Authorization Code Flow isn't the only option. Here's when each grant type applies:
| Grant Type | Use Case | User Involved? | Still Recommended in 2026? |
|---|
| Authorization Code + PKCE | Web apps, mobile apps, SPAs | Yes | Yes — the default choice |
| Client Credentials | Machine-to-machine (APIs, cron jobs) | No | Yes |
| Device Authorization | Smart TVs, CLI tools, IoT | Yes (on separate device) | Yes |
| Implicit | Legacy SPAs | Yes | No — removed in OAuth 2.1 |
| Resource Owner Password | Legacy migration only | Yes | No — removed in OAuth 2.1 |
| Refresh Token | Extending sessions | No | Yes — required for good UX |
Client Credentials is the one most backend developers should know. When your server needs to talk to another API without any user context — think cron jobs, data pipelines, service-to-service calls — this is the flow:
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: 'YOUR_SERVICE_CLIENT_ID',
client_secret: 'YOUR_SERVICE_CLIENT_SECRET',
scope: 'read:data',
}),
})
No user. No redirect. No consent screen. Just two servers exchanging credentials.
Five Security Mistakes I've Seen (And Made)
OAuth is secure by design but fragile in implementation. The spec is deliberately vague and flexible, which means the security burden falls on developers. Here are the mistakes I see most:
1. Not validating the redirect URI
If your auth server accepts any redirect URI (or uses loose pattern matching), an attacker can steal authorization codes by redirecting users to their own domain. OAuth 2.1 requires exact string matching for redirect URIs. Do this today.
2. Skipping the state parameter
The state parameter prevents CSRF attacks. Without it, an attacker can trick a user into authorizing the attacker's account instead of their own. Improper state validation is one of the most common OAuth vulnerabilities. Always generate a random state, store it in the session, and verify it when the callback comes.
3. Storing tokens in localStorage
localStorage is accessible to any JavaScript running on your page. One XSS vulnerability and an attacker can steal every token. Use httpOnly cookies for token storage on the server side. If you must use client-side storage, use sessionStorage at minimum — but httpOnly cookies are the right answer.
4. Requesting too many scopes
Requesting scope=everything because "we might need it later" is lazy and dangerous. Users see the permissions and many will refuse. Request the minimum scopes you need. You can always request additional scopes later with incremental authorization.
5. Not using PKCE
Even if you have a backend and a client secret, PKCE protects against authorization code interception attacks that can happen at the network level. There's no reason not to use it. OAuth 2.1 makes it mandatory for all clients.
OAuth 2.1: What's Changing
OAuth 2.1 is the next version of the standard, currently in late-stage IETF draft. It doesn't add new features — it removes dangerous ones and makes best practices mandatory.
| Change | OAuth 2.0 | OAuth 2.1 |
|---|
| PKCE | Optional | Required for all clients |
| Implicit grant | Allowed | Removed |
| Password grant | Allowed | Removed |
| Redirect URI matching | Loose matching common | Exact string matching required |
| Bearer tokens in URLs | Allowed | Forbidden |
| Refresh tokens (public clients) | No restrictions | Must be sender-constrained or one-time use |
Honestly? If you're building something new in 2026, just follow OAuth 2.1 rules now. The spec is stable. Major organizations like Anthropic already require it for their MCP authorization spec. Don't wait for the final RFC.
The Decision Framework: Build vs. Use a Provider
You have three options for implementing OAuth:
Option 1: Use a managed provider (Auth0, Clerk, Supabase Auth, Firebase Auth)
Best for: most startups, solo developers, teams without security expertise.
You get OAuth flows, user management, session handling, and token storage out of the box. Auth0's free tier supports up to 25,000 monthly active users. Clerk and Supabase Auth integrate directly with Next.js.
Option 2: Use an open-source library (NextAuth.js / Auth.js, Passport.js, Lucia)
Best for: teams that want control without building everything from scratch.
NextAuth.js (now Auth.js) supports 80+ OAuth providers with a few lines of config. You own the data. You control the session strategy. But you're responsible for security updates.
Option 3: Build it yourself from scratch
Best for: almost nobody.
Unless you're building an auth server product, you don't need this. The surface area for security mistakes is enormous. The maintenance burden never ends. Every CVE in an OAuth library affects you directly.
| Criteria | Managed Provider | Open-Source Library | DIY |
|---|
| Time to implement | Hours | Days | Weeks to months |
| Security responsibility | Provider | Shared | Entirely yours |
| Cost at scale | Paid tiers | Free | Engineering time |
| Customization | Limited | Moderate | Unlimited |
| Maintenance | Zero | Updates needed | Ongoing |
| Best for | Startups, MVPs | Mid-size teams | Auth product companies |
My recommendation: start with Auth.js or a managed provider. Migrate later if you outgrow it. I've never seen a startup fail because they chose Auth0 over building OAuth from scratch. I've seen plenty fail because they spent months building auth instead of their product.
The exception is enterprise companies with specific compliance requirements — SOC 2, HIPAA, FedRAMP — where you need full control of the auth pipeline and audit trail. Even then, consider managed providers with compliance certifications before going DIY.
A Real Implementation (Next.js + Auth.js)
Here's what a minimal OAuth implementation actually looks like in 2026 using Auth.js with Next.js:
// src/auth.ts
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
})
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
That's it. Two files. Google and GitHub OAuth with PKCE, CSRF protection, secure token storage, and session management — all handled for you.
The Authorization Code Flow with PKCE, the state parameter validation, the token exchange, the refresh token rotation — it's all happening under the hood. Auth.js implements the full spec so you don't have to.
What I Actually Think
OAuth 2.0 is a good protocol with a terrible developer experience. The spec is flexible to the point of being confusing. The terminology is inconsistent. The flow diagrams in most tutorials look like they were designed to frighten people away from understanding auth.
But the core idea is simple and brilliant: don't share passwords between services. Issue limited, time-bound tokens instead. And let the user decide what access to grant.
The biggest mistake developers make isn't getting the flow wrong. It's spending weeks building OAuth from scratch when battle-tested libraries exist. Auth is the one part of your app where "not invented here" syndrome can actually get your users' data stolen. Use Auth.js. Use Clerk. Use Auth0. Whatever. Just don't roll your own unless you have a dedicated security team reviewing every line.
And if you take one thing from this article: enable PKCE on everything. Use exact redirect URI matching. Validate the state parameter. These three things alone would prevent the majority of OAuth vulnerabilities that researchers keep finding.
OAuth 2.1 is coming and it makes all of these mandatory. Get ahead of it now.
Sources
- Identity and Access Management Market Size — Fortune Business Insights
- OAuth Market Share in IAM — 6sense
- Password Statistics 2026 — DeepStrike
- Compromised Credential Statistics 2025 — DeepStrike
- Common OAuth 2.0 Grant Types — Authgear
- OAuth 2.0 Authentication Vulnerabilities — PortSwigger
- Common OAuth Vulnerabilities — Doyensec
- OAuth 2.1 — oauth.net
- OAuth 2.1 vs 2.0: What Developers Need to Know — Stytch
- MCP, OAuth 2.1, PKCE, and the Future of AI Authorization — Aembit
- Authorization Code Flow with PKCE — Auth0
- Google OAuth Vulnerability Exposes Millions — Truffle Security