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
Intro: 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):
- Click on "Create client"
- Select Application type: "Web Application"
- Give it a "Name"
- On "Authorized JavaScript origin":
- On
Authorized redirect URIs
: - Change the domains accordingly and click "Save"
- You are given Client ID and Client Secret, please save them because you won't able to view the Client Secret after closing the window.
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.
How 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:
- User clicks "Sign in with Google"
- Your server creates a secure Google authorization URL
- User gets redirected to Google's sign-in page
- User enters signs in with their credentials
- Google redirects back to your app with special codes
- Your server exchanges those special codes for access token, id token, refresh token.
- You decode the user's information from the tokens
- You create a session for the user
- User can now access protected routes
- Your app validates the session on each request
Step 1: Starting the Authentication
When users click your sign-in button, they hit this endpoint:
fetch("/api/auth/google")
Step 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).
The Authorization URL
The authorization URL we generate contains several important parameters:
key | description |
---|---|
state | A random string for security, PKCE flow |
code_challenge | A hash of state (aka codeVerifier ) |
scope | Options on what user info we want access to (e.g id, email, profile) |
client_id | App Client Id from Google Console |
redirect_uri | Where Google sends users back after successful sign-in (e.g our server endpoint) |
access_type=offline | Let 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
Implementation
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
});
Cookies 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 onlyDate
typeDate
type accepts as arguments typenumber
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
Why 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.
Why PKCE Matters: The Security Flow Explained
The PKCE flow prevents attacks by using temporary, unique codes state
and codeVerifier
. Here is the flow explained:
- We know the authorization URL must contain the
state
andcodeVerifier
- We generate a random
state
andcodeVerifier
and store them as key-value pairs. Cookies is the best option. - Google keeps the
codeVerifier
and returns thestate
- When Google redirects back to our server, Google sends back the
state
and we use it to find ourcodeVerifier
- When exchanging codes (access_token) with Google, we prove we're the same client using the
codeVerifier
- If approved, Google send back us the user's
access_token
, which is our end goal
Why Storing in Cookies?
Cookies are perfect for this because:
- Key-value structure: Easy to store
state
as key andcodeVerifier
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
Step 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.
Step 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 usestate
to obtain thecodeVerifier
app.get('/auth/google/callback', async (req, res) => {
const { state, code } = req.query;
...
});
Step 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")
...
});
Understanding 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 userid_token
: A JWT containing user identity information (name, email, picture)refresh_token
: Long-term token to get new access tokens when they expireexpires_in
: How long the access token is valid (usually 1 hour)
Step 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())
...
});
Understanding 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:
Field | Description |
---|---|
email | The user’s Google account email. |
email_verified | Boolean — Whether Google has verified this email. Always true . |
name | The user’s full name. |
given_name | First name. |
family_name | Last name. |
picture | A 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):
Field | Description |
---|---|
iss | Issuer — Always "https://accounts.google.com" for Google ID tokens. It tells you who issued the token (Google). |
azp | Authorized Party — The Client ID of your OAuth app. If you’re verifying the token, this should match your app’s client ID. |
aud | Audience — This is also your app’s Client ID. It ensures the token was meant for your app. |
sub | Subject — 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.
Step 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:
- Json Web Token strategy session
- 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 libraryjsonwebtoken
popular in Express.js@hono/jwt
Hono.js (a express.js alternative) has their own JWT library@oslo/jwt
same creator ofArticjs.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:
key | description |
---|---|
payload | an object storing any information (our case name, email, image) |
secreyKey | a random secret that will be encrypted with the HS256 algorithm by default |
Creating 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
key | description | type | e.g |
---|---|---|---|
iat | issue at time - the token was issued at this time | number | 1721195207 (since epoch in seconds) |
nbf | not before - the token is not valid before this time | number | 1721195207 |
exp | expiration - the token becomes invalid after wthis time | number | 1721198807 |
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
Complete 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
Step 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
...
})
Logging Out
Logging out is simpler:
app.post('/auth/logout', (req, res) => {
res.clearCookie('session_token');
res.text('Logged out successfully');
});
Keeping 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.
Outro
Security Best Practices
- Always use HTTPS in production
- Store refresh tokens encrypted in your database
- Set secure cookie flags for session cookies
- Use environment variables for client secrets
- Implement rate limiting on auth endpoints
Troubleshooting 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.
What'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