Google Auth with Next.js 13

Implementing Google Authentication with Next.js Route Handlers

Profile photo

by Mark MurrayOctober 06, 20238 min read

  1. Install dependencies
  2. Create an app in Google Cloud
  3. Update env file with environment variables
  4. Create a custom Iron implementation
  5. Handle sessions
  6. Create API routes for login redirects
  7. Create authentication helpers
  8. 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 state cookie, validate session expiry, and use the current asynchronous cookies() 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 googleapis

Create 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.

Google Developer Console


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:

.env
# 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_SECRET to the functions by default. This simplifies the implementation throughout the app.

lib/iron.ts
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

lib/api/auth.ts
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:

  1. /api/auth/google/login
  2. /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.

lib/api/google.ts
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:

app/api/auth/google/login/route.ts
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 email, picture, name, givenName and familyName will be returned in the encoded id_token.

app/api/auth/google/callback/route.ts
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.

app/authenticate.ts
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');
    }
  };
}
app/authenticate.ts
// 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.

app/your-route/page.tsx
import { authenticateRoute } from '../authenticate';

export default authenticateRoute(YourRoute);

async function YourRoute(context) {
  return <div>Your authenticated route</div>;
}
app/api/model/route.ts
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.

Copyright © Mark Murray, 2026