Next.js App Router has everything you need to add SSO without spinning up a heavy auth library. The shortest path: a thin integration against a managed provider plus a signed session cookie. Here’s the full walkthrough — the pattern LoginWith’s Next.js setup uses under the hood.
Setup
npm install jose # for JWT verification
That’s the only dependency. Everything else is in the standard lib and Next.js itself.
The callback route
app/auth/google/callback/route.ts:
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { SignJWT, jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'))
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
// Verify state against the stored value (from a cookie set at /auth/google/start)
const stored = (await cookies()).get('oauth_state')?.value
if (!state || state !== stored) return NextResponse.json({ error: 'bad_state' }, { status: 400 })
// Exchange code for tokens
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: code!,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
grant_type: 'authorization_code',
}),
})
const { id_token } = await tokenRes.json()
// Verify the ID token and extract the user
const { payload } = await jwtVerify(id_token, JWKS, {
issuer: 'https://accounts.google.com',
audience: process.env.GOOGLE_CLIENT_ID,
})
// Create our own session JWT
const session = await new SignJWT({ sub: payload.sub, email: payload.email, name: payload.name })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(new TextEncoder().encode(process.env.SESSION_SECRET!))
const res = NextResponse.redirect('/app')
res.cookies.set('session', session, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return res
}
The middleware
middleware.ts:
import { NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
export async function middleware(req: Request) {
const token = req.cookies.get('session')?.value
if (!token) return NextResponse.redirect(new URL('/login', req.url))
try {
await jwtVerify(token, new TextEncoder().encode(process.env.SESSION_SECRET!))
return NextResponse.next()
} catch {
return NextResponse.redirect(new URL('/login', req.url))
}
}
export const config = {
matcher: ['/app/:path*'],
}
Reading the user in a server component
import { cookies } from 'next/headers'
import { jwtVerify } from 'jose'
export default async function Page() {
const token = (await cookies()).get('session')?.value
const { payload } = await jwtVerify(token!, new TextEncoder().encode(process.env.SESSION_SECRET!))
return <div>Hello {payload.email as string}</div>
}
What you don’t need
- An auth library (for this specific use case)
- A provider component wrapping your tree
- useSession hooks — server components read the cookie directly
- Callback URL registration beyond what Google asks for
What you still need
- PKCE (I skipped it here for brevity; add it in production)
- A /auth/google/start route that redirects to Google with state and PKCE
- Sign-out: clear the cookie
- Rate limiting on the callback
Total LOC for a complete implementation: ~150. Total learning curve for why each line is there: hours, not weeks.