- Install dependencies
- Create an app in Google Cloud
- Update env file with environment variables
- Create a custom Iron implementation
- Handle sessions
- Create API routes for login redirects
- Create authentication helpers
- Authenticate routes
Update (August 2026): The examples now verify Google's ID token rather than merely decoding it, correlate OAuth requests with a short-lived
statecookie, validate session expiry, and use the current asynchronouscookies()API. These changes also work with later App Router releases.
Install dependencies
The implementation outlined in this guide uses the following packages. Install
them using whatever package manager you're using for your app (yarn, npm,
pnpm):
npm install iron-webcrypto googleapisCreate an app in Google Cloud
Next you'll want to create a new application in the Google Developer Console. Once you've created a new app, make sure it's selected in the header, and go to https://console.cloud.google.com/apis/credentials and click "Create credentials" and select "OAuth client ID".
When you've created a new OAuth Client ID, click on its name to update the redirect and origin information.
Update env file with environment variables
Now that you have a Google application with credentials set up, you'll want to
add them to your .env file:
# Google Auth secrets
GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID"
GOOGLE_CLIENT_SECRET="YOUR_GOOGLE_CLIENT_SECRET"
GOOGLE_REDIRECT_URI="YOUR_GOOGLE_REDIRECT_URI"
# Application secrets
AUTH_SESSION_COOKIE="YOUR_PREFERRED_COOKIE_NAME"
AUTH_SESSION_SECRET="YOUR_PREFERRED_SECRET_FOR_HASHING_THE_COOKIE"Create a custom Iron implementation
To support runtimes that expose the Web Crypto API, we'll use iron-webcrypto.
It allows us to pass globalThis.crypto to the seal and unseal methods.
We'll create two custom Iron implementations here, one for seal and another
for unseal. These functions will be used to encrypt and decrypt the cookies
respectively.
Note that we're abstracting the secret variable here and passing
AUTH_SESSION_SECRETto the functions by default. This simplifies the implementation throughout the app.
import * as Iron from 'iron-webcrypto';
const { AUTH_SESSION_SECRET } = process.env;
if (!AUTH_SESSION_SECRET) {
throw new Error('AUTH_SESSION_SECRET is required');
}
const _crypto = globalThis.crypto;
type SealArgs = Parameters<typeof Iron.seal>;
/**
* Usage: Iron.seal(session, optionalOptions)
*/
function seal(object: SealArgs[1], options?: SealArgs[3]) {
return Iron.seal(
_crypto,
object,
AUTH_SESSION_SECRET,
options ?? Iron.defaults,
);
}
type UnsealArgs = Parameters<typeof Iron.unseal>;
/**
* Usage: Iron.unseal(cookie, optionalOptions)
*/
function unseal(sealed: UnsealArgs[1], options?: UnsealArgs[3]) {
return Iron.unseal(
_crypto,
sealed,
AUTH_SESSION_SECRET,
options ?? Iron.defaults,
);
}
const iron = {
seal,
unseal,
};
export default iron;Handle sessions
import { NextResponse } from 'next/server'
import Iron from '@/lib/iron'
const { AUTH_SESSION_COOKIE, NODE_ENV } = process.env
if (!AUTH_SESSION_COOKIE) {
throw new Error('AUTH_SESSION_COOKIE is required')
}
export interface GoogleProfile {
sub: string
email: string
name: string
picture?: string
given_name?: string
family_name?: string
locale?: string
}
export interface UserSession {
issued: number
expires: number
user: {
id: string | number
email: string
name: string
photoUrl?: string
firstName?: string
lastName?: string
locale?: string
}
}
export function setAuthCookie(
response: NextResponse,
encrypted: string,
expires: number,
) {
response.cookies.set(AUTH_SESSION_COOKIE, encrypted, {
expires: new Date(expires),
httpOnly: true,
secure: NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
priority: 'high',
})
}
export function createSession(
profile: GoogleProfile,
userId: string | number,
expires: number,
): UserSession {
return {
issued: Date.now(),
expires,
user: {
id: userId,
email: profile.email,
name: profile.name,
photoUrl: profile.picture,
firstName: profile.given_name,
lastName: profile.family_name,
locale: profile.locale,
},
}
}
export async function encryptSession(session: UserSession) {
return Iron.seal(session)
}
export async function decryptSession(
session: string,
): Promise<UserSession> {
const value = (await Iron.unseal(session)) as UserSession
if (!value?.expires || value.expires <= Date.now()) {
throw new Error('Session has expired')
}
return value
}Create API routes for login redirects
We will need two routes to make Google auth work:
/api/auth/google/login/api/auth/google/callback
We'll hit the first URL using a button in our UI, which will redirect us to a Google UI to choose an email account. If authentication is successful it will redirect us to the second URL.
Start by creating a Google helper file under lib. This will provide us with
the authorization URL that we'll redirect users to.
import { google } from 'googleapis';
const scopes = ['openid', 'email', 'profile'];
const { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI } =
process.env;
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET || !GOOGLE_REDIRECT_URI) {
throw new Error('Google OAuth environment variables are required');
}
export const oauth2Client = new google.auth.OAuth2(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
);
export function createAuthorizationUrl(state: string) {
return oauth2Client.generateAuthUrl({
access_type: 'online',
scope: scopes,
include_granted_scopes: true,
state,
});
}Next, create the /api/auth/google/login route by creating
/api/auth/google/login/route.ts in the app directory, and add the following:
import { NextResponse } from 'next/server';
import { createAuthorizationUrl } from '@/lib/api/google';
// Endpoint: /api/auth/google/login
export async function GET() {
const state = crypto.randomUUID();
const response = NextResponse.redirect(createAuthorizationUrl(state));
response.cookies.set('google.oauth.state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60,
path: '/',
});
return response;
}The last step is to create the /callback route. To do so, create a
/api/auth/google/callback/route.ts file under the app directory.
When the user hits the callback route, we'll check for a code query parameter
in the request and use it to fetch a token from the Google API. If the code is
valid, Google will return an object containing an id_token parameter. The
id_token string is a signed JWT containing the user profile information. We
will verify its signature, issuer, audience, and expiry with Google's client
library, then use the verified claims to find or create the user. Finally, we'll
create a session, set it in a cookie, and redirect to the logged-in page.
If the email doesn't exist in our database already, we can choose to create a new entry, or deny them based on an access-control list. The decision here will depend on your use case.
Note that we don't need any additional Google API to fetch the user information since the
picture,name,givenNameandfamilyNamewill be returned in the encodedid_token.
import { NextRequest, NextResponse } from 'next/server';
import {
createSession,
encryptSession,
setAuthCookie,
} from '@/lib/api/auth';
import { oauth2Client as googleAuth } from '@/lib/api/google';
import { upsertUser } from '@/lib/data/users';
// Endpoint: /api/auth/google/callback?code=&error=
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get('code');
const error = req.nextUrl.searchParams.get('error');
const state = req.nextUrl.searchParams.get('state');
const expectedState = req.cookies.get('google.oauth.state')?.value;
if (error) {
return NextResponse.json({ error }, { status: 401 });
}
if (!code || !state || !expectedState || state !== expectedState) {
return NextResponse.json({ error: 'Invalid OAuth state' }, { status: 400 });
}
const { tokens } = await googleAuth.getToken(code);
if (!tokens.id_token) {
return NextResponse.json({ error: 'Missing ID token' }, { status: 401 });
}
const ticket = await googleAuth.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID!,
});
const profile = ticket.getPayload();
if (!profile?.sub || !profile.email || !profile.name) {
return NextResponse.json({ error: 'Incomplete Google profile' }, { status: 401 });
}
// Implement this function with your application's database layer.
const user = await upsertUser({
providerId: profile.sub,
email: profile.email,
name: profile.name,
image: profile.picture,
});
const expires = tokens.expiry_date ?? Date.now() + 60 * 60 * 1000;
const session = createSession(
{
sub: profile.sub,
email: profile.email,
name: profile.name,
picture: profile.picture,
given_name: profile.given_name,
family_name: profile.family_name,
locale: profile.locale,
},
user.id,
expires,
);
const encrypted = await encryptSession(session);
const response = NextResponse.redirect(new URL('/logged-in', req.url));
setAuthCookie(response, encrypted, expires);
response.cookies.delete('google.oauth.state');
return response;
}Create authentication helpers
Next we'll create two helpers to allow us to easily authenticate page and API
routes: authenticateRoute and authenticateApiRoute.
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest, NextResponse } from 'next/server';
import { decryptSession } from '@/lib/api/auth';
const { AUTH_SESSION_COOKIE } = process.env;
if (!AUTH_SESSION_COOKIE) {
throw new Error('AUTH_SESSION_COOKIE is required');
}
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
// Authenticate a server-rendered page. Cookie mutation is not allowed while
// rendering a Server Component, so an invalid session simply redirects.
export function authenticateRoute(route: Function) {
return async function (context: PageProps) {
const cookieStore = await cookies();
const session = cookieStore.get(AUTH_SESSION_COOKIE)?.value;
if (!session) {
redirect('/login');
}
try {
const decrypted = await decryptSession(session);
return route({ ...context, user: decrypted?.user });
} catch {
redirect('/login');
}
};
}// Authenticate a server-side route
export function authenticateApiRoute(apiRoute: Function) {
return async function (request: NextRequest) {
const session = request.cookies.get(AUTH_SESSION_COOKIE)?.value;
if (!session) {
return NextResponse.json(
{ data: 'Unauthorized' },
{
status: 401,
statusText: 'Unauthorized',
},
);
}
try {
const decrypted = await decryptSession(session);
return apiRoute(request, decrypted);
} catch (error) {
console.error(error);
return NextResponse.json(
{ data: 'Unauthorized' },
{
status: 401,
statusText: 'Unauthorized',
},
);
}
};
}Authenticate routes
Now that you have authentication in place, you can protect your routes as you
wish by wrapping the exports in authenticateRoute or authenticateApiRoute
from app/authenticate.ts.
import { authenticateRoute } from '../authenticate';
export default authenticateRoute(YourRoute);
async function YourRoute(context) {
return <div>Your authenticated route</div>;
}import type { NextRequest } from 'next/server';
import { UserSession } from '@/lib/api/auth';
import { authenticateApiRoute } from '@/app/authenticate';
export const POST = authenticateApiRoute(create);
async function create(request: NextRequest, session: UserSession) {
// Route handling... Notice the user `session` is available here.
}And there you have it - Google authentication with Next. Obviously this is a boilerplate to get you started, but much more can be done here to make the implementation more secure.
