Add SSO to your Next.js app in 15 minutes

Server components + a signed session cookie. The cleanest pattern, walked through end to end.

· LoginWith team

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.

Want auth that just works?

Get started with LoginWith