Ismat Samadov
  • Tags
  • About

© 2026 Ismat Samadov

RSS
16 min read/3 views

OAuth 2.0 Explained Like You're Building It From Scratch

A step-by-step breakdown of OAuth 2.0 by building every piece from scratch: flows, tokens, PKCE, security mistakes, and what to use in 2026.

OAUTHSecurityAuthenticationWeb DevTypeScript

Related Articles

Python 3.14 T-Strings Will Change How You Write Python Forever

16 min read

Temporal for Backend Developers: Durable Execution Makes Complex Backends Boring

18 min read

EU AI Act Hits August 2026: Most Companies Are Not Ready (Compliance Checklist for Devs)

19 min read

Enjoyed this article?

Get new posts delivered to your inbox. No spam, unsubscribe anytime.

On this page

  • The Problem OAuth Solves (It's Not Login)
  • The Four Players
  • Scopes: The Permission System
  • Building It From Scratch: The Authorization Code Flow
  • Step 1: Register your app
  • Step 2: Redirect the user
  • Step 3: User gives consent
  • Step 4: Authorization code comes back
  • Step 5: Exchange the code for tokens
  • Step 6: Use the access token
  • Step 7: Refresh when expired
  • PKCE: The Extension You Must Use
  • The Other Grant Types (And When to Use Each)
  • Five Security Mistakes I've Seen (And Made)
  • 1. Not validating the redirect URI
  • 2. Skipping the state parameter
  • 3. Storing tokens in localStorage
  • 4. Requesting too many scopes
  • 5. Not using PKCE
  • OAuth 2.1: What's Changing
  • The Decision Framework: Build vs. Use a Provider
  • A Real Implementation (Next.js + Auth.js)
  • What I Actually Think
  • Sources

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.

RoleWhat It IsExample
Resource OwnerThe user who owns the dataYou
ClientThe app that wants accessA to-do app wanting your Google Calendar
Authorization ServerIssues tokens after user consentsGoogle's OAuth server
Resource ServerHolds the protected dataGoogle 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:

  1. Verify the state parameter matches what you sent in Step 2. If it doesn't, someone is trying a CSRF attack. Reject the request.
  2. 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 TypeUse CaseUser Involved?Still Recommended in 2026?
Authorization Code + PKCEWeb apps, mobile apps, SPAsYesYes — the default choice
Client CredentialsMachine-to-machine (APIs, cron jobs)NoYes
Device AuthorizationSmart TVs, CLI tools, IoTYes (on separate device)Yes
ImplicitLegacy SPAsYesNo — removed in OAuth 2.1
Resource Owner PasswordLegacy migration onlyYesNo — removed in OAuth 2.1
Refresh TokenExtending sessionsNoYes — 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.

ChangeOAuth 2.0OAuth 2.1
PKCEOptionalRequired for all clients
Implicit grantAllowedRemoved
Password grantAllowedRemoved
Redirect URI matchingLoose matching commonExact string matching required
Bearer tokens in URLsAllowedForbidden
Refresh tokens (public clients)No restrictionsMust 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.

CriteriaManaged ProviderOpen-Source LibraryDIY
Time to implementHoursDaysWeeks to months
Security responsibilityProviderSharedEntirely yours
Cost at scalePaid tiersFreeEngineering time
CustomizationLimitedModerateUnlimited
MaintenanceZeroUpdates neededOngoing
Best forStartups, MVPsMid-size teamsAuth 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

  1. Identity and Access Management Market Size — Fortune Business Insights
  2. OAuth Market Share in IAM — 6sense
  3. Password Statistics 2026 — DeepStrike
  4. Compromised Credential Statistics 2025 — DeepStrike
  5. Common OAuth 2.0 Grant Types — Authgear
  6. OAuth 2.0 Authentication Vulnerabilities — PortSwigger
  7. Common OAuth Vulnerabilities — Doyensec
  8. OAuth 2.1 — oauth.net
  9. OAuth 2.1 vs 2.0: What Developers Need to Know — Stytch
  10. MCP, OAuth 2.1, PKCE, and the Future of AI Authorization — Aembit
  11. Authorization Code Flow with PKCE — Auth0
  12. Google OAuth Vulnerability Exposes Millions — Truffle Security