Authentication: Google OAuth2.0 with PKCE

Authentication: Google OAuth2.0 with PKCE

Ever wondered how "Sign in with Google" actually works? Let's build it from scratch using OAuth 2.0 with PKCE (Proof Key for Code Exchange) and Express.js. No magic libraries or auth libraries wrappers to understand what’s happening under the hood.

We won't use third-party auth wrappers, but we will use helper functions from libraries like Arcticjs.dev and Oslojs.dev to handle the tedious parts.

Before starting, we must set up OAuth2.0 to our app in Google Console

LinkIconIntro: Setting up Google Console

Head to Google Console and create a new project:

  • Click the box next to the Google logo: "Select Project" → "New project"
  • Give it a name and create it
  • Click on left menu or press key (.) then navigate to API Services → OAuth consent screen

Click on "Get started" and fill up the forms:

  • On "App information" form, fill up App name, User support email.
  • On "Audience" form, choose "External".
  • On "Contact Information" form, enter your email for contact.
  • Click on "Agree". Then "Create".

If you notice on the left-side navbar you have: Overview, Branding, Audience, Clients, Data Access, Verification Center.

On Branding:

  • Fill up non-required forms.
  • On "Authorized domains", enter your domain where your app will be published on the internet, e.g "https://my-website.com"

On Audience:

  • On "Testing", you can publish your app once you have your domain and your app is on production. For now, do not publish.
  • On "User Type", skip.
  • On "OAuth user cap" shows the amount of users signed in within your app. No actions needed.
  • On "Test users", please enter the emails that you will be using for testing your app in development.

On Clients (very important step):

On Data Access:

  • Click on "Add or remove scopes", we will add scopes so that our app can access different user personal information.
    • Check the following: ".../auth/userinfo.email", ".../auth/userinfo.profile", and "openid"
    • Then "Update"

On Verification Center, no action required.

Our app is ready for OAuth2.0 with Google.

LinkIconHow OAuth2.0 Works

Think of OAuth like getting a visitor badge at a secure building. You show your ID to the front desk (Google), they verify you're legitimate, and give you a temporary badge (access_token) to access the building (your app).

Here's the 10-step flow:

  1. User clicks "Sign in with Google"
  2. Your server creates a secure Google authorization URL
  3. User gets redirected to Google's sign-in page
  4. User enters signs in with their credentials
  5. Google redirects back to your app with special codes
  6. Your server exchanges those special codes for access token, id token, refresh token.
  7. You decode the user's information from the tokens
  8. You create a session for the user
  9. User can now access protected routes
  10. Your app validates the session on each request

LinkIconStep 1: Starting the Authentication

When users click your sign-in button, they hit this endpoint:

fetch("/api/auth/google")

LinkIconStep 2: Creating Google Secure Authorization URL

In this step, we'll create a secure authorization URL that redirects users to Google's sign-in page.

We'll use the OAuth 2.0 with PKCE strategy and the Arctic.js library to simplify the process (we'll explain why use Arctic later).

LinkIconThe Authorization URL

The authorization URL we generate contains several important parameters:

keydescription
stateA random string for security, PKCE flow
code_challengeA hash of state (aka codeVerifier)
scopeOptions on what user info we want access to (e.g id, email, profile)
client_idApp Client Id from Google Console
redirect_uriWhere Google sends users back after successful sign-in (e.g our server endpoint)
access_type=offlineLet us refresh tokens without requiring the user to re-authenticate

This how the authorization URL look like:

https://accounts.google.com/o/oauth2/v2/auth?response_type=code&
state=kICJlO1pwqpdOpiVILq0l0H0uEOPuib9AaZLbulXhr0&
code_challenge=j85-0b3mGK6pFwSHpOHMdQ46_z3X2CEHinEm0EWbwls&
code_challenge_method=S256&
scope=openid+profile+email&
client_id=1234.apps.googleusercontent.com&
redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fgoogle%2Fcallback&
access_type=offline

LinkIconImplementation

Here is the endpoint implementation and authorization URL generation with Arctic:

import * as arctic from "arctic";
const google = new arctic.Google(clientId, clientSecret, redirectURI);
 
app.get('/auth/google', (req, res) => {
  // Create a secret codes for security (PKCE)
  const state = arctic.generateState()
  const codeVerifier = arctic.generateCodeVerifier()
  const scopes = ["openid", "profile", "email"]
  // Build the Google sign-in URL
  const url = google.createAuthorizationURL(state, codeVerifier, scopes)
  googleAuthUrl.searchParams.set("access_type", "offline")
  
  // Save `state` as key and `code` as value for later use
  res.cookies(state, codeVerifier. {
		httpOnly: true,
		sameSite: "Lax", // "Lax" must be set here for OAuth flow
		secure: process.env.NODE_ENV === "production",
		expires: new Date(Date.now() + 1000 * 60 * 3), // only 3m for login process
		path: "/"
	})
  res.redirect(url); // redirect user to google auth
});

LinkIconCookies options

You may notice that when we are setting cookies, we are passing some options. Here is the documentation:

httpOnly: boolean — Prevents JavaScript access to the cookie

  • Frontend JavaScript cannot read this cookie using document.cookie
  • Protects against XSS (Cross-Site Scripting) attacks
  • Only the server can read this cookie when requests are made
  • Essential for sensitive data like authentication tokens

sameSite: "Strict" | "Lax" | "None" | undefined — Controls when cookies are sent with cross-site requests

  • "Strict" - Cookie is NEVER sent with cross-site requests
  • "Lax" - Cookie sent with safe cross-site requests (like clicking a link, OAuth redirects)
  • "None" - Cookie sent with all cross-site requests (requires secure: true)
  • Protects against CSRF (Cross-Site Request Forgery) attacks
  • "Strict" is most secure

secure: boolean — Requires HTTPS connection

  • true - Cookie only sent over encrypted HTTPS connections
  • false - Cookie can be sent over HTTP (development only)
  • Prevents cookie theft through network sniffing
  • Should always be true in production

expires: Date | undefined — Sets when the cookie expires

  • expires accepts only Date type
  • Date type accepts as arguments type number in miliseconds
  • e.g Date.now() + (1000 * 60 * 3) equivalent to "expires in 3 minutes in miliseconds"
  • e.g exp: new Date( Date.now() + 1000 * 60 * 3 ) is valid format
  • Browser automatically deletes cookie after this date
  • Alternative can use maxAge: 1000 * 60 * 3 (milliseconds from now)

path: string — Defines which URLs can access this cookie

  • "/" - Cookie available for entire website
  • "/admin" - Cookie only available for /admin routes and subdirectories
  • Helps limit cookie scope for security

LinkIconWhy Arctic Makes our Life Simple

Building this URL manually is tedious:

import crypto from "crypto"
const state = crypto.randomBytes(32).toString('base64url');
const code_verifier = crypto
  .createHash('sha256')
  .update(code_verifier)
  .digest('base64url');
const searchParams = {
  client_id: 'YOUR_GOOGLE_CLIENT_ID',
  redirect_uri: 'YOUR_REDIRECT_URI',
  response_type: 'code',
  scope: 'openid email profile',
  state: state,
  code_challenge: code_verifier,
  code_challenge_method: 'S256',
  access_type: 'offline',
  prompt: 'consent',
};
const params = new URLSearchParams(searchParams);
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
url.search = params.toString();
url // the authorization url is ready

Arctic handles all this complexity for us:

import * as arctic from "arctic";
const google = new arctic.Google(clientId, clientSecret, redirectURI);
// Create security codes (PKCE flow)
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
// What data we want to access
const scopes = ["openid", "profile", "email"];
// Build the Google sign-in Authorization URL
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
url.searchParams.set("access_type", "offline");
// -> use `url` to redirect user

Why?

  • Google API are subject to change so we might need to update this code in the future.
  • Arctic library maintainers updates Google API and other providers, so we let them do that work for us.
  • That's why we'll use Arctic.

LinkIconWhy PKCE Matters: The Security Flow Explained

The PKCE flow prevents attacks by using temporary, unique codes state and codeVerifier. Here is the flow explained:

  1. We know the authorization URL must contain the state and codeVerifier
  2. We generate a random state and codeVerifier and store them as key-value pairs. Cookies is the best option.
  3. Google keeps the codeVerifier and returns the state
  4. When Google redirects back to our server, Google sends back the state and we use it to find our codeVerifier
  5. When exchanging codes (access_token) with Google, we prove we're the same client using the codeVerifier
  6. If approved, Google send back us the user's access_token, which is our end goal

LinkIconWhy Storing in Cookies?

Cookies are perfect for this because:

  • Key-value structure: Easy to store state as key and codeVerifier as value
  • User-specific: Each user gets their own cookies, so no data mixing
  • Automatic handling: Browsers send cookies with every request automatically, programmers don't have to do anything
  • Temporary: We only need this data for the brief OAuth flow

LinkIconStep 3-4: User Signs In

Google shows the sign-in page, user enters credentials, and if valid, Google redirects back to our our app server endpoint, called the callback endpoint, with special codes attached in the searchParams.

LinkIconStep 5: The Callback Endpoint

When authentication succeeds, Google sends user back to our callback endpoint or redirect_uri endpoint with two important parameters:

  • code: A temporary authorization code to exchange for:
    • Access Token: A token that gives authorization to retrieve user data from Google.
    • ID Token: A token containing user identity information, such as email and name.
    • Refresh Token: A token to refresh the access_token
  • state: The same random string generated in Step 2. We will use state to obtain the codeVerifier
app.get('/auth/google/callback', async (req, res) => {
	const { state, code } = req.query;
	...
});

LinkIconStep 6: Exchanging for access token

Remember the state and codeVerifier we saved in cookies? Now it's time to retrieve them and use them for exchanging codes with Google. This process is called PKCE.

Good part is that these two special codes belongs to a single user thanks to the nature of cookies http-only, so these codes would never be shared between users. In other words, each user will generate their own state and codeVerifier.

import * as arctic from "arctic";
const google = new arctic.Google(clientId, clientSecret, redirectURI);
 
app.get('/auth/google/callback', async (req, res) => {
  const { state, code } = req.query;
	
	// Get `state` from cookies and validate codeVerifier
	const codeVerifier = req.cookies[state]
	if (!codeVerifier) return new Error("codeVerifier does not exists in cookies")
 
	// Exchange for access_token, id_token, refresh_token
	const tokens = google.validateAuthorizationCode(code, codeVerifier)
	if (!tokens) return new Error("the codeVerifier does not match")
	
	...
});

LinkIconUnderstanding the Token Response

The tokens object contains several important pieces:

{
  "access_token": "ya29.a0AS...",
  "refresh_token": "ya29.a0AS...",
  "expires_in": 3599,
  "scope": "openid https://www.googleapis.com/auth/userinfo.profile...",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSU..."
}

The tokens object contains:

  • access_token: Your "visitor badge" to access Google's APIs on behalf of the user
  • id_token: A JWT containing user identity information (name, email, picture)
  • refresh_token: Long-term token to get new access tokens when they expire
  • expires_in: How long the access token is valid (usually 1 hour)

LinkIconStep 7: Getting User Data

The id_token is a JWT containing user information. Arcticjs.dev makes decoding easy:

import * as arctic from "arctic";
const google = new arctic.Google(clientId, clientSecret, redirectURI);
 
app.get('/auth/google/callback', async (req, res) => {
  const { state, code } = req.query;
	
	// Validate Authorization Code
	const codeVerifier = req.cookies[state]
	if (!codeVerifier) return // check that codeVerifier exists
 
	// Exchange for access_token
	const tokens = google.validateAuthorizationCode(code, verifier)
 
	// Get the claims
	const claims = arctic.decodeIdToken(tokens.idToken())
	
	...
});

LinkIconUnderstanding the User Claims

The decoded ID token is called claims, a object with the user's information:

{
	"iss": "https://accounts.google.com",
	"azp": "97530073273...",
	"aud": "97530073273...",
	"sub": "11683935105098...",
	"email": "user@gmail.com",
	"email_verified": true,
	"at_hash": "OtjCppf9...",
	"name": "first_name last_name",
	"picture": "https://lh3.googleusercontent.com/a/ACg...",
	"given_name": "first_name",
	"family_name": "last_name",
	"iat": 1752550096,
	"exp": 1752553696
}

Key fields we'll usually use:

FieldDescription
emailThe user’s Google account email.
email_verifiedBoolean — Whether Google has verified this email. Always true.
nameThe user’s full name.
given_nameFirst name.
family_nameLast name.
pictureA URL to the user’s Google profile picture (usually 96x96px thumbnail). You can show it in your UI or store it in your DB.

Security fields (usually saved in Account table in a DB):

FieldDescription
issIssuer — Always "https://accounts.google.com" for Google ID tokens. It tells you who issued the token (Google).
azpAuthorized Party — The Client ID of your OAuth app. If you’re verifying the token, this should match your app’s client ID.
audAudience — This is also your app’s Client ID. It ensures the token was meant for your app.
subSubject — A unique user ID string (stable and never reused). Can use this as the primary key in your user table.

Now we got all user data and access_token, we can then proceed to save this data into our JWT session.

LinkIconStep 8: JWT Strategy Session

Once we have the user's information, we need them stay logged in across requests with Sessions.

There are two strategy sessions:

  1. Json Web Token strategy session
  2. Database strategy session

Read this to learn more about the differences.

We'll use the JWT strategy for the simple reason that we want to avoid database calls on every request to get user data. If read operations is not a problem in you application, then use Database sessions.

What is a JWT? It's a way to securely transmit information between parties as a JSON object. Learn how it works here.

Popular JWT libraries are:

  • jose the most popular JWT library
  • jsonwebtoken popular in Express.js
  • @hono/jwt Hono.js (a express.js alternative) has their own JWT library
  • @oslo/jwt same creator of Articjs.dev but library is deprecated

For this tutorial, we will use jsonwebtoken library.

 
function sign(payload: object, secretKey: string, options: object): string;

To sign a token, we need the payload and secretKey.

Parameters:

keydescription
payloadan object storing any information (our case name, email, image)
secreyKeya random secret that will be encrypted with the HS256 algorithm by default

LinkIconCreating JWT Sessions

import { sign } from "jsonwebtoken"
 
const JWT_SECRET = process.env.JWT_SECRET
const EXPIRES_IN_S = 60 * 60 * 24 * 7; // 7 days in seconds
 
function createSession(user): string {
	const now = Math.floor(Date.now() / 1000); // now in seconds
	const exp = now + EXPIRES_IN_S; // expires in 7 days
 
	// session data
  const payload = {
    userId: user.id,
    email: user.email,
    name: user.name,
		avatar: user.avatar,
		iat: now,
		nbf: now,
		exp: exp
    // Don't put sensitive data in the JWT
  };
 
	const encodedJWT = await sign(payload, JWT_SECRET)
 
  return encodedJWT
}

Good to know:

By convention, iat, nbf and exp must be type number in seconds since epoch (called NumericDate).

NumericDate:
It's a JSON numeric value representing the number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap seconds. This is equivalent to the IEEE Std 1003.1, 2013 Edition [POSIX.1] definition "Seconds Since the Epoch", in which each day is accounted for by exactly 86400 seconds, other than that non-integer values can be represented. See RFC 3339 [RFC3339] for details regarding date/times in general and UTC in particular.

Good to know:

Some JWT libraries ask you to include these in the payload: iat, nbf, exp

keydescriptiontypee.g
iatissue at time - the token was issued at this timenumber1721195207 (since epoch in seconds)
nbfnot before - the token is not valid before this timenumber1721195207
expexpiration - the token becomes invalid after wthis timenumber1721198807

Example:

  • iat: Token was created at 2025-07-17 06:00:07 UTC
  • nbf: Don't accept the token before 2025-07-17 06:00:07 UTC
  • exp: Token is invalid after 2025-07-17 07:00:07 UTC

LinkIconComplete Callback Implementation

Back to our server callback endpoint.

app.get('/auth/google/callback', async (req, res) => {
  const { state, code } = req.query;
	
	// Validate Authorization Code
	const codeVerifier = req.cookies[state]
	if (!codeVerifier) return // check that codeVerifier exists
 
	// Exchange for access_token
	const tokens = google.validateAuthorizationCode(code, codeVerifier)
 
	// Get the claims
	const claims = arctic.decodeIdToken(tokens.idToken())
 
	// Store user in the database
	const user = await saveUserDB(claims)
 
	const jwt = createSession(user)
	// Use `JWT Session Strategy` by storing the JWT in an HTTP-only cookie
	res.cookie("session_token", jwt, {
		httpOnly: true,     
		sameSite: "Lax", // must be set to "Lax" so cookies set after google redirects
		secure: process.env.NODE_ENV === "production",
		expires: new Date(Date.now() + (1000 * 60 * 60 * 24 * 7)), // expires in 7 days
		path: "/"
	})
	
	return res.status(200).json.({ message: "Login successful", user })
});

Here is the cookies option docs

LinkIconStep 9 and 10: Sending a Request

Now that users are authenticated and their session is stored in cookies, users can access protected routes from our server, and the good part is that cookies are sent to our server automatically by the browser — programmers don't have to set up nothing.

The only thing our app server must do is veryfing the JWT is valid. If so, then the user is authenticated and no need of DB calls to get user data.

import { verify } from "jsonwebtoken"
 
const JWT_SECRET = process.env.JWT_SECRET
 
app.get('/notes/:userId', async (req, res) => {
	// get jwt and check if valid
	const jwt = req.cookie['session_token']
	const session = verify(jwt, JWT_SECRET)
	if (!session) throw AuthenticationError();
 
	// otherwise user is authenticated
	//   user is allow to access their personal notes
	// we also have access to user data
	session.userId
	session.name
	session.email
	session.avatar
	...
})

LinkIconLogging Out

Logging out is simpler:

app.post('/auth/logout', (req, res) => {
  res.clearCookie('session_token');
  res.text('Logged out successfully');
});

LinkIconKeeping Sessions Alive

Access tokens expire quickly (usually in an hour). To keep users logged in, we use the refresh token:

import { verify } from "jsonwebtoken"
import * as arctic from "arctic";
const google = new arctic.Google(clientId, clientSecret, redirectURI);
const JWT_SECRET = process.env.JWT_SECRET
 
authRoute.get("/refresh_token", async (c) => {
	// get user id from session
	const jwt = req.cookie['session_token']
	const session = verify(jwt, JWT_SECRET)
	const userId = session.userId
 
	// get the user account
	const account = getUserAccount(userId)
 
	// if provider token still valid then early return
	if (account.expiresAt > Date.now()) return
 
	// otherwise continue refreshToken process
	const tokens = await google.refreshAccessToken(account.refreshToken)
 
	// delete user's `acccess_token` in DB if stored
	...
})

Important: Store refresh tokens securely and never expose them to the frontend. They're like master keys to user accounts.

LinkIconOutro

LinkIconSecurity Best Practices

  1. Always use HTTPS in production
  2. Store refresh tokens encrypted in your database
  3. Set secure cookie flags for session cookies
  4. Use environment variables for client secrets
  5. Implement rate limiting on auth endpoints

LinkIconTroubleshooting Common Issues

Not getting a refresh token? Make sure you're using access_type: 'offline' and prompt: 'consent'.

PKCE errors? Double-check that your code_verifier is stored correctly between the initial request and callback.

Token expired errors? Implement proper refresh token logic before making API calls.

LinkIconWhat's Next?

You now have a complete OAuth 2.0 authentication flow! Users can sign in with Google, and your app can maintain their sessions securely.

Things to consider:

  • In this tutorial WE DID NOT CHECK ERRRORS OR THROWN ERRORS IN ASYNC OPERATIONS
  • try-catch for those places

To do next:

  • Adding more OAuth providers
  • Setting up proper error handling and logging
  • If you need the ability to revoke sessions immediately, consider database session strategy