Persist Google OAuth Refresh Tokens with Next.js & Redis
Step-by-step Next.js guide using Upstash Redis to secure offline Google OAuth refresh tokens and enable background API…

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building an MCP server to connect Google Search Console data directly to Claude when I hit a common roadblock: I needed persistent, long-term access to a third-party API without forcing a user to log in every hour. Most auth libraries like NextAuth.js are fantastic for user sessions, but they often add too much surface area when you just need a secure, backend-to-backend handshake.
After experimenting with few different approaches, I found that a slim, custom implementation using Next.js API routes and Upstash Redis provided the perfect balance of security and control. This guide walks you through building that flow from scratch, focusing on the critical "offline" access that keeps your integration running in the background.
Prerequisites and Setup
Before writing any code, we need to pull in the Redis client and configure our environment variables. This implementation assumes you are using the Next.js App Router and have a project initialized.
First, install the Upstash Redis SDK:
npm install @upstash/redis
Next, create a .env.local file in your root directory. You will need your Google OAuth credentials (from the Google Cloud Console) and your Upstash Redis connection details:
GOOGLE_OAUTH_CLIENT_ID="your-client-id" GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret" GOOGLE_OAUTH_REDIRECT_URI="http://localhost:3000/api/auth/google/callback" KV_REST_API_URL="your-upstash-url" KV_REST_API_TOKEN="your-upstash-token"
The non-null assertions (!) in our code below will ensure these variables are present; without them, the application will catch the configuration error immediately at runtime rather than failing silently later.
The Strategy: Redis for Token Persistence
The most important part of this setup is where you store the keys. Since we are dealing with sensitive OAuth tokens that need to be accessed by your API routes, we need a fast, secure, and persistent store.
Our storage module needs to handle the initial token save and, crucially, preserve the refresh token during subsequent updates. Google only sends the refresh token on the first authorization, so if you overwrite the record later without it, you lose background access.
// File: src/lib/google/token-storage.ts
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
export interface GoogleTokens {
access_token: string
refresh_token?: string
scope: string
token_type: string
expiry_date: number
}
const GOOGLE_TOKEN_KEY = 'google:tokens:admin'
export async function storeGoogleTokens(tokens: GoogleTokens): Promise<void> {
if (!tokens.refresh_token) {
const existing = await getGoogleTokens()
if (existing && existing.refresh_token) {
tokens.refresh_token = existing.refresh_token
}
}
await redis.set(GOOGLE_TOKEN_KEY, JSON.stringify(tokens))
}
export async function getGoogleTokens(): Promise<GoogleTokens | null> {
const data = await redis.get<string | GoogleTokens>(GOOGLE_TOKEN_KEY)
if (!data) return null
return typeof data === 'string' ? JSON.parse(data) : data as GoogleTokens
}
This code establishes a reliable way to interact with your "vault". The logic inside storeGoogleTokens is a safety net: it checks if the incoming payload is missing a refresh token—which happens during standard refreshes—and stitches the existing one back in. This prevents your background service from suddenly "dying" because the refresh token was wiped.
The OAuth Handshake Logic
With storage ready, we need to handle the two core parts of the OAuth handshake: getting the user to the consent screen and then exchanging the resulting code for actual tokens.
We use the standard fetch API here to avoid extra dependencies. The key parameters to watch are access_type: 'offline' and prompt: 'consent'. These are what tell Google to give you a long-lived refresh token instead of just a temporary access token.
// File: src/lib/google/auth.ts
import { GoogleTokens } from './token-storage'
const CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID!
const CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET!
const REDIRECT_URI = process.env.GOOGLE_OAUTH_REDIRECT_URI!
export function generateAuthUrl(): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/webmasters.readonly',
access_type: 'offline',
prompt: 'consent',
})
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`
}
export async function exchangeCodeForTokens(code: string): Promise<GoogleTokens> {
const params = new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI
})
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
})
if (!response.ok) throw new Error('Failed to exchange code')
return await response.json()
}
The generateAuthUrl function creates the entry point for your integration. By forcing prompt: 'consent', we ensure that every time you re-authenticate, Google confirms the permissions and provides a fresh refresh token. Note that we import GoogleTokens from our storage file to keep the type system consistent.
Implementing the API Endpoints
The final piece is exposing this logic via Next.js API routes. You need a "Start" route to trigger the redirect and a "Callback" route to catch the code Google sends back.
Note: These snippets use the @/ path alias. If your project isn't configured for this, you can update your tsconfig.json paths mapping or use relative imports (e.g., ../../../../lib/...).
// File: src/app/api/auth/google/start/route.ts
import { NextResponse } from 'next/server'
import { generateAuthUrl } from '@/lib/google/auth'
export async function GET() {
return NextResponse.redirect(generateAuthUrl())
}
This "Start" route is your trigger. When you visit this URL in your browser, it generates the Google authorization URL and redirects you instantly to the consent screen.
// File: src/app/api/auth/google/callback/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { exchangeCodeForTokens } from '@/lib/google/auth'
import { storeGoogleTokens } from '@/lib/google/token-storage'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get('code')
if (!code) return NextResponse.json({ error: 'Missing code' }, { status: 400 })
try {
const tokens = await exchangeCodeForTokens(code)
await storeGoogleTokens(tokens)
return NextResponse.json({
success: true,
message: 'Integration successful. Tokens stored.'
})
} catch (error) {
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 })
}
}
The callback route is where the magic happens. It takes the one-time code from the URL, performs the secure backend-to-backend exchange with Google, and commits the resulting tokens to Redis. Once this is done, your server is "linked" and can perform operations on behalf of the user entirely in the background.
By keeping this flow isolated from your main user authentication, you maintain a clean separation of concerns. Your application knows how to talk to Google as a service, regardless of who is currently logged into your frontend.
This custom approach gives you the ultimate flexibility to build deep integrations like MCP servers or automated reporting tools while keeping your security surface area as small as possible.
Thanks, Matija