Every developer has been there. You need login. You Google 'add Google login Next.js'. You find a Stack Overflow answer from 2019 that mentions OAuth2, then a Medium post that mentions PKCE, then a GitHub issue where someone argues about implicit flow being deprecated, and by 11pm you're reading RFC 6749 wondering if you should just build your own auth from scratch. You won't. Don't.
OAuth2 is actually not that complicated once you strip away the enterprise jargon. The problem is most explanations assume you care about every flow, every grant type, and every edge case. You don't. You're building a web app and you want users to log in with Google or GitHub without storing their passwords. Let's get there.
What OAuth2 Actually Is (and What It Isn't)
OAuth2 is an authorization framework. Not authentication — authorization. This distinction matters and also trips up basically everyone. OAuth2 answers 'can this app access this resource on your behalf?' It doesn't answer 'who are you?' That's OpenID Connect (OIDC), which is a thin layer built on top of OAuth2 that adds an ID token and a /userinfo endpoint. When you 'log in with Google', you're actually doing OAuth2 + OIDC together, but most people call the whole thing OAuth.
OAuth2 = authorization (can this app do X on your behalf). OIDC = authentication (who is this user). In practice, every 'social login' uses both. The ID token is how you know who logged in.
The other thing to know: OAuth2 has multiple 'flows' (officially called grant types) because it was designed for different scenarios. A browser-based single page app has different security constraints than a server-side app, which has different constraints than a CLI tool or a smart TV. This is why there are multiple flows. You probably only need to understand two of them.
The Two Flows You Actually Need to Know
Authorization Code Flow is the one you use for server-side apps and Next.js. Authorization Code Flow with PKCE is the one you use for SPAs and mobile apps. Everything else — implicit flow, client credentials, device flow — you'll either never use or you'll know exactly when you need them when the time comes.
The Authorization Code Flow works like this: your app redirects the user to the OAuth provider (Google, GitHub, etc.), the user logs in there and approves permissions, the provider redirects back to your app with a short-lived code, your server exchanges that code for tokens by making a server-to-server request with your client secret. The key thing: the client secret never touches the browser. It stays on your server.
PKCE (pronounced 'pixie', yes really) extends this for clients that can't keep a secret — like a React SPA where there's no real server. Instead of a client secret, you generate a random code verifier, hash it into a code challenge, send the challenge at the start, then prove you have the original verifier when you exchange the code. It's cryptographic proof that the entity exchanging the code is the same one that started the flow.
The Authorization Code Flow, Step by Step
Here's what actually happens when a user clicks 'Login with GitHub' on your Next.js app:
- Your app generates a random `state` parameter (CSRF protection) and stores it in the session/cookie
- User is redirected to GitHub's auth URL with your client_id, redirect_uri, scopes, and the state
- User logs in on GitHub and approves your requested permissions
- GitHub redirects back to your redirect_uri with ?code=xxx&state=yyy
- Your server verifies the state matches (prevents CSRF), then POSTs to GitHub's token endpoint with the code + your client secret
- GitHub returns an access_token (and optionally a refresh_token and id_token)
- Your server uses the access_token to fetch the user's profile, creates a session, done
// app/api/auth/github/route.ts — Step 1: Redirect to GitHub
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import crypto from 'crypto'
export async function GET() {
const state = crypto.randomBytes(16).toString('hex')
// Store state in a cookie for CSRF verification
cookies().set('oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10 minutes
})
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID!,
redirect_uri: `${process.env.APP_URL}/api/auth/github/callback`,
scope: 'read:user user:email',
state,
})
redirect(`https://github.com/login/oauth/authorize?${params}`)
}// app/api/auth/github/callback/route.ts — Step 2: Handle the callback
import { cookies } from 'next/headers'
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')
const storedState = cookies().get('oauth_state')?.value
// CSRF check — don't skip this
if (!state || !storedState || state !== storedState) {
return NextResponse.json({ error: 'Invalid state' }, { status: 400 })
}
// Exchange code for tokens
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID!,
client_secret: process.env.GITHUB_CLIENT_SECRET!, // server-only
code,
redirect_uri: `${process.env.APP_URL}/api/auth/github/callback`,
}),
})
const { access_token, error } = await tokenResponse.json()
if (error || !access_token) {
return NextResponse.json({ error: 'Token exchange failed' }, { status: 400 })
}
// Fetch user profile
const userResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` },
})
const user = await userResponse.json()
// Now create your session/JWT and redirect the user
// ... your session logic here
const response = NextResponse.redirect(new URL('/dashboard', request.url))
response.cookies.delete('oauth_state') // clean up
return response
}That's the whole thing. No magic, no black box. The parts people most often mess up: forgetting to validate the state parameter (CSRF), putting the client secret somewhere it can be accessed from the browser, and not handling the case where the user denies permission (GitHub will redirect back with ?error=access_denied instead of a code).
Tokens: Access, Refresh, and ID — What's the Difference
Once you complete the flow, you get tokens back. Here's what they are and what you do with them:
- **Access token**: Short-lived (usually 1 hour). Use this to call the provider's API on behalf of the user. Don't store this long-term.
- **Refresh token**: Long-lived. Use this to get a new access token when the current one expires. Some providers (GitHub notably) don't issue refresh tokens for basic OAuth. Store this securely, server-side only.
- **ID token**: JWT containing the user's identity (name, email, picture). This is the OIDC part. Verify the signature before trusting anything in it.
Common mistake: using the access token as your own app's session token. Don't. The access token is for talking to the provider's API. Your app should issue its own session (JWT or server-side session) tied to the user record in your database. If GitHub rotates their tokens or the user revokes access, your app's sessions should be independently manageable.
The State Parameter and Why CSRF Actually Matters Here
CSRF in OAuth is slightly different from form CSRF and it's worth understanding because it's one of the real attack vectors. Without state validation, an attacker could trick your user into completing an OAuth flow that logs them into the attacker's account. The attack works like this: attacker starts an OAuth flow, gets the redirect URL with the code, but doesn't complete it. Instead they trick the victim into visiting that callback URL. Now the victim's browser is authenticated as the attacker's account.
This sounds contrived but it's a real attack against payment flows where an attacker could link their bank account to a victim's profile. The state parameter prevents it: you generate a random value, store it server-side (or in an httpOnly cookie), and verify it matches when the callback comes back. If someone tampers with the OAuth flow, the state won't match and you reject it.
// ID token verification — if you're using Google or any OIDC provider
import * as jose from 'jose'
async function verifyGoogleIdToken(idToken: string) {
// Fetch Google's public keys
const JWKS = jose.createRemoteJWKSet(
new URL('https://www.googleapis.com/oauth2/v3/certs')
)
const { payload } = await jose.jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: process.env.GOOGLE_CLIENT_ID,
})
// payload.sub is the stable user ID — use this, not email
// payload.email can change, sub won't
return {
id: payload.sub as string,
email: payload.email as string,
name: payload.name as string,
picture: payload.picture as string,
}
}Always use the `sub` (subject) claim as the primary identifier for a user, not their email. Emails change. The `sub` is a stable, provider-specific ID that won't change even if the user updates their email address. We learned this the hard way when someone changed their Google account email and effectively got locked out because we were keying sessions on email.
What to Actually Store in Your Database
When someone logs in with OAuth, you need to link the external identity to your internal user record. The pattern that works:
- A `users` table with your own internal ID, email, name, etc.
- An `oauth_accounts` table with provider, provider_user_id (sub), access_token, refresh_token, expires_at, and a foreign key to users
- One user can have multiple OAuth accounts (they log in with both GitHub and Google)
- On login: look up oauth_accounts by (provider, provider_user_id). If found, load that user. If not, check if an account with the same email exists. If yes, link it. If no, create new user + account.
The email-matching step is a judgment call with security implications. Automatically linking accounts by email means if someone controls a Google account with your email, they can access your app account. Some apps ask for explicit confirmation before linking. Depends on your threat model — for most SaaS apps, email matching is fine.
Should You Use a Library or Roll It Yourself?
Honest answer: use Auth.js (next-auth) or a dedicated auth service (Clerk, Lucia, Better Auth) unless you have a specific reason not to. Not because OAuth is too complex to implement — as you've seen, it's not that bad — but because auth libraries also handle session management, token refresh, account linking, security edge cases, and database adapters. That's a lot of surface area to own yourself.
Where rolling it yourself makes sense: you have unusual requirements, you want to deeply understand the flow for learning, or you're building something like an OAuth provider yourself. For a standard SaaS app with Google/GitHub/email login, there's no shame in reaching for Auth.js. The templates on peal.dev ship with auth already wired up specifically so you don't have to make this decision repeatedly across every project.
If you do use Auth.js, understand what it's doing under the hood. Don't treat it as magic. When something goes wrong (and OAuth is one of those things where something always goes wrong in prod — wrong redirect URI, misconfigured environment variables, token expiry edge cases) you need to know where to look.
The most common OAuth bug in production is a mismatch between the redirect URI you registered with the provider and the one your app is sending. It's always this. Check it first.
Also: test your OAuth flow in an incognito window before deploying. Browser state and cookies from local dev will mess with your testing in ways that don't reflect what a real user experiences. We once spent 45 minutes debugging a login issue that was just a stale cookie from localhost. The incognito window showed it working perfectly. Classic.
OAuth2 is one of those things that looks complicated because the spec covers every possible use case, but for a web app in 2025, it's basically: redirect user → get code → exchange for token → verify identity → create session. The state parameter for CSRF, the client secret on the server only, and stable user IDs from `sub`. That's most of what you need to know to build login that actually works.
