Every time we add social login to a project, we end up in the same Wikipedia spiral. OAuth 2.0 spec. RFC 6749. 'Implicit flow deprecated'. PKCE. Token introspection. At some point you're reading about grant types at 11pm and you've completely forgotten you just wanted users to log in with Google.
So here's the practical version. We're going to cover the flows you'll actually encounter, explain what they do in plain language, and tell you which one to reach for without making you read an RFC.
First: What OAuth 2.0 Actually Is (and Isn't)
OAuth 2.0 is an authorization framework, not an authentication protocol. Yes, we know — you're using it for login. The world decided to use an authorization tool for authentication anyway, and then OpenID Connect (OIDC) got layered on top to make it official. When you see 'Sign in with Google', that's OAuth 2.0 + OIDC together. The distinction matters when you're debugging, but for getting started, don't let it stop you.
The core idea is simple: instead of your app handling the user's Google password, Google handles it and then tells your app 'yes, this person is who they say they are' by issuing a token. Your app never sees the password. That's it. Everything else is just different variations of how that conversation between your app and Google happens.
The Four Flows You'll Actually Encounter
OAuth 2.0 defines four main grant types, and a few extensions have been added over the years. Here's an honest breakdown of when you'll see each one:
- Authorization Code — web apps with a backend server
- Authorization Code + PKCE — single-page apps, mobile apps, or any public client
- Client Credentials — machine-to-machine, no user involved
- Device Authorization — TV apps, CLI tools, devices with no browser
The Implicit Flow is intentionally off this list. It was deprecated in 2019 and you shouldn't use it for new projects. If you're maintaining old code that uses it, migrate to Authorization Code + PKCE when you get the chance.
Authorization Code Flow: The Standard One
This is the flow you want for a Next.js app where you have a real backend — server components, API routes, the whole stack. Here's what happens step by step:
- User clicks 'Login with Google'
- Your app redirects them to Google's authorization server with your client_id, requested scopes, and a redirect_uri
- User logs in at Google and approves your app
- Google redirects back to your redirect_uri with a short-lived authorization code
- Your server exchanges that code for an access token (and optionally a refresh token) using your client_secret
- You use the access token to fetch user info or call APIs on their behalf
The key thing here is step 5: the code exchange happens server-to-server. Your client_secret never touches the browser. That's the whole security advantage of this flow over Implicit.
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
// Always verify state to prevent CSRF attacks
const storedState = request.cookies.get('oauth_state')?.value
if (!state || state !== storedState) {
return NextResponse.redirect(new URL('/login?error=invalid_state', request.url))
}
if (!code) {
return NextResponse.redirect(new URL('/login?error=missing_code', request.url))
}
// Exchange the code for tokens — this happens server-side
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!, // never sent to browser
redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
grant_type: 'authorization_code',
}),
})
const tokens = await tokenResponse.json()
if (!tokenResponse.ok) {
console.error('Token exchange failed:', tokens)
return NextResponse.redirect(new URL('/login?error=token_exchange', request.url))
}
// tokens.access_token — use this to call Google APIs
// tokens.id_token — JWT with user info (if you requested openid scope)
// tokens.refresh_token — store this securely to get new access tokens
// ... create session, set cookies, redirect to dashboard
return NextResponse.redirect(new URL('/dashboard', request.url))
}Always validate the state parameter. It's a random value you generate before the redirect and compare after. Skipping it leaves you open to CSRF attacks. This is the most commonly skipped step in tutorials, and also the one that will bite you.
PKCE: Authorization Code for Public Clients
PKCE (Proof Key for Code Exchange, pronounced 'pixie' if you want to sound like you know what you're doing at a conference) solves a real problem: what if you can't keep a secret? Mobile apps and SPAs can't safely store a client_secret — anyone can decompile an app or inspect a JS bundle and find it. PKCE replaces the client_secret with a cryptographic challenge that's generated fresh for each login attempt.
The flow is the same as Authorization Code, with two additions:
- Before redirecting, generate a random code_verifier (a random string, 43-128 chars)
- Hash it with SHA-256 to get code_challenge, send that with the authorization request
- When exchanging the code for tokens, send the original code_verifier
- The authorization server verifies they match — proves it's really you making the exchange
// utils/pkce.ts
export async function generatePKCE() {
// Generate a random code verifier
const array = new Uint8Array(32)
crypto.getRandomValues(array)
const codeVerifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
// Hash it to get the code challenge
const encoder = new TextEncoder()
const data = encoder.encode(codeVerifier)
const digest = await crypto.subtle.digest('SHA-256', data)
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
return { codeVerifier, codeChallenge }
}
// Usage: initiate login
export async function initiateLogin() {
const { codeVerifier, codeChallenge } = await generatePKCE()
const state = crypto.randomUUID()
// Store verifier and state — sessionStorage for SPAs, httpOnly cookie on server
sessionStorage.setItem('pkce_verifier', codeVerifier)
sessionStorage.setItem('oauth_state', state)
const params = new URLSearchParams({
response_type: 'code',
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
redirect_uri: import.meta.env.VITE_REDIRECT_URI,
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`
}Worth noting: even if you do have a backend (Next.js server components, API routes), PKCE is still a good idea to add on top of the standard Authorization Code flow. It adds defense in depth. Libraries like Auth.js (formerly NextAuth) enable it by default now, which is one reason we like using them rather than rolling this by hand every time.
Client Credentials: When There's No User
Sometimes you're not logging in a human. You have a cron job, a background worker, or a microservice that needs to call an API. There's no user to redirect to a login page. That's where Client Credentials comes in.
The flow is refreshingly simple: your server sends its client_id and client_secret directly to the authorization server and gets back an access token. No redirects, no user interaction, no authorization code. Just credentials in, token out.
// lib/machine-token.ts
// Client Credentials flow for service-to-service calls
let cachedToken: { token: string; expiresAt: number } | null = null
export async function getMachineToken(): Promise<string> {
// Return cached token if it's still valid (with 60s buffer)
if (cachedToken && Date.now() < cachedToken.expiresAt - 60_000) {
return cachedToken.token
}
const response = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.SERVICE_CLIENT_ID!,
client_secret: process.env.SERVICE_CLIENT_SECRET!,
scope: 'read:orders write:notifications',
}),
})
if (!response.ok) {
throw new Error(`Failed to get machine token: ${response.status}`)
}
const data = await response.json()
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
}
return cachedToken.token
}
// Usage in a background job
async function syncOrders() {
const token = await getMachineToken()
const response = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${token}` },
})
// ...
}Cache the token. It's valid for however long `expires_in` says (usually an hour). Fetching a new one on every request is wasteful and will get you rate limited. We learned this specifically when a deploy script was hammering Auth0's token endpoint at 2am during a data migration — their rate limiter kicked in and the whole thing ground to a halt.
The State Parameter and Other Security Bits You Can't Skip
Security in OAuth isn't just about picking the right flow. There are a few small things that tutorials often gloss over that will genuinely cause you pain if you skip them.
- state parameter: Generate a random value, store it in a cookie or session, verify it matches when the redirect comes back. Prevents CSRF attacks where a malicious site tricks your app into completing an OAuth flow with the attacker's account.
- redirect_uri validation: Register your exact redirect URI with the OAuth provider. Never use wildcards. An attacker who can intercept the authorization code to an unintended redirect URI can steal the account.
- Token storage: Access tokens belong in memory or httpOnly cookies, not localStorage. localStorage is accessible to any JS on the page, including third-party scripts.
- HTTPS only: OAuth over HTTP is not OAuth, it's just credential theft waiting to happen. Your redirect_uri must be HTTPS in production.
- Scope minimization: Only request the scopes you actually need. Users see the permissions screen — asking for 'access to all your Google Drive files' to power a login button is going to hurt your conversion rate.
Should You Implement This Yourself?
Honestly? Probably not from scratch. The concepts above are worth understanding so you're not debugging blind, but for most Next.js projects, Auth.js (NextAuth) or a hosted provider like Clerk or Lucia handles all of this correctly out of the box. They implement PKCE, state validation, token rotation, and session management — things that are tedious to get right and easy to get wrong.
Where rolling your own makes sense: you have very specific requirements (custom token storage, unusual provider integration, compliance requirements), or you're building auth infrastructure itself. Otherwise, you're spending time on plumbing that doesn't differentiate your product.
The templates on peal.dev ship with Auth.js pre-configured — Google, GitHub OAuth, plus email magic links — so you get the working implementation and can actually read the code to understand how it hangs together. Good way to learn the flow without starting from a blank file.
Quick Reference: Which Flow for Which Situation
- Next.js app with App Router or Pages Router → Authorization Code (Auth.js handles this for you)
- React SPA with no backend → Authorization Code + PKCE
- Mobile app (React Native, etc.) → Authorization Code + PKCE
- Backend service calling another API → Client Credentials
- CLI tool or TV app → Device Authorization Flow
- Legacy app using Implicit Flow → Migrate to PKCE when you can
The right OAuth flow depends on one question: can your client keep a secret? If it runs on a server you control, yes — use Authorization Code. If it runs on a user's device (browser, phone, desktop app), no — use PKCE. If there's no user at all, use Client Credentials.
OAuth is one of those things that feels more complicated than it is when you're reading the spec, but clicks into place once you've implemented it a couple of times. The mental model is simple: delegate the login to someone else, get a token back, validate that token, done. The rest is just different shapes of that same conversation depending on where your code runs.
