A lightweight, secure, and developer-friendly template for implementing Google OAuth 2.0 (Authorization Code Flow with PKCE) across a React Router (v7) frontend and a Fastify backend.
The Philosophy: Zero bloated wrappers. No Auth0, no Supabase, no Firebase. Just pure HTTP-only cookies, JSON Web Tokens (JWT), and the official Fastify plugins, giving you 100% control over your authentication flow and user data.
- Monorepo Ready: Designed for
pnpmworkspaces (apps/web,apps/backend). - Bulletproof Security: Uses
HttpOnly,Secure, andSameSite=laxcookies. Tokens are never exposed to client-side JavaScript. - Seamless SSR: React Router
loaderfunctions safely forward session cookies to the backend for server-side route protection. - Type-Safe: Full TypeScript support, including Fastify plugin namespace augmentation.
- Frontend: React Router (Framework Mode), Tailwind CSS, TypeScript
- Backend: Fastify,
@fastify/oauth2,@fastify/jwt,@fastify/cookie - Package Manager: pnpm
- Go to the Google Cloud Console.
- Create a new project and navigate to APIs & Services > Credentials.
- Click Create Credentials > OAuth client ID (Application type: Web application).
- Add your frontend URL to Authorized JavaScript origins:
http://localhost:5190
- Add your backend callback URL to Authorized redirect URIs:
http://localhost:8080/auth/google/callback
- Save the generated Client ID and Client Secret.
Create a .env file in your backend directory:
NODE_ENV=development
VITE_ENV=development
BACKEND_PORT=8080
WEB_URL=http://localhost:5170
API_BASE_URL=http://localhost:8080
GOOGLE_CLIENT_ID=YOUR_GOOGLE_GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_GOOGLE_CLIENT_SECRET
JWT_SECRET=edd00844d336c590bc83083d399a41e3183edacfed9704387992faf1a3c46a405420046f4a0e3009c82ea2b915201ba9f3693de57bb449aab3aeea847d55a512
Install the required plugins:
cd apps/backend
pnpm add @fastify/oauth2 @fastify/cookie @fastify/jwt @fastify/cors
To ensure TypeScript recognizes the @fastify/oauth2 plugin, create a type declaration file (e.g., types/fastify.d.ts):
import "fastify";
import type { OAuth2Namespace } from "@fastify/oauth2";
declare module "fastify" {
interface FastifyInstance {
googleOAuth2: OAuth2Namespace;
}
interface FastifyReply {
success<D>(data: D, message?: string, statusCode?: number): FastifyReply;
}
}Set up the plugins and the OAuth routes. The backend handles the callback, signs a JWT, and issues an HttpOnly cookie.
import type { FastifyInstance } from "fastify";
import fastifyCookie from "@fastify/cookie";
import fastifyCors from "@fastify/cors";
import jwt from "@fastify/jwt";
import oauthPlugin from "@fastify/oauth2";
import { ApiError } from "~/utils/ApiError";
export async function server(fastify: FastifyInstance) {
// 1. CORS Setup (Critical for cross-origin cookies)
await fastify.register(fastifyCors, {
origin: [process.env.WEB_URL || "http://localhost:5190"],
credentials: true,
});
await fastify.register(fastifyCookie);
// 2. JWT Setup (Configured to read from the 'session' cookie)
await fastify.register(jwt, {
secret: process.env.JWT_SECRET || "super-secret-key-change-in-prod",
cookie: { cookieName: "session", signed: false },
});
// 3. OAuth2 Configuration
await fastify.register(oauthPlugin, {
name: "googleOAuth2",
credentials: {
client: {
id: process.env.GOOGLE_CLIENT_ID as string,
secret: process.env.GOOGLE_CLIENT_SECRET as string,
},
auth: oauthPlugin.GOOGLE_CONFIGURATION,
},
startRedirectPath: "/auth/google",
callbackUri: "http://localhost:8080/auth/google/callback",
scope: ["profile", "email"],
pkce: "S256",
});
// 4. Handle Google Callback
fastify.get("/auth/google/callback", async (req, reply) => {
const { token } = await fastify.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(req);
const userInfoResponse = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${token.access_token}` },
});
const userInfo = await userInfoResponse.json();
const sessionToken = fastify.jwt.sign({
id: userInfo.id,
email: userInfo.email,
name: userInfo.name,
picture: userInfo.picture,
});
reply.setCookie("session", sessionToken, {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
reply.redirect("http://localhost:5190/");
});
// 5. Verify Session Route
fastify.get("/api/me", async (req, reply) => {
try {
await req.jwtVerify({ onlyCookie: true });
return reply.success(req.user, "Success");
} catch (err) {
throw new ApiError("Unauthorized", 401, "UNAUTHORIZED");
}
});
// 6. Logout Route
fastify.post("/api/logout", async (_req, reply) => {
reply.clearCookie("session", { path: "/" });
return reply.success(null, "Logged out successfully");
});
}In React Router framework mode, loader functions run on the server. To authorize a user before rendering, the loader must extract the incoming cookie from the browser's request and forward it to the Fastify backend.
// apps/web/app/routes/home.tsx
import { useLoaderData, useRevalidator } from "react-router";
import type { Route } from "./+types/home";
export function meta({}: Route.MetaArgs) {
return [{ title: "Google OAuth Template" }];
}
// 1. SSR Loader: Forwards the browser's cookie to Fastify to verify the session
export const loader = async ({ request }: Route.LoaderArgs) => {
const cookie = request.headers.get("Cookie") || "";
const userResponse = await fetch("http://localhost:8080/api/me", {
method: "GET",
headers: {
"Content-Type": "application/json",
Cookie: cookie, // Forwarding the HttpOnly cookie
},
});
const user = await userResponse.json();
return { user };
};
export default function Home() {
const { user } = useLoaderData<typeof loader>();
const revalidator = useRevalidator();
// 2. Logout Handler: Clears the HttpOnly cookie via the API
const handleLogout = async () => {
try {
await fetch("http://localhost:8080/api/logout", {
method: "POST",
credentials: "include", // Required to send the cookie across ports
});
// Re-run the loader to update UI state
revalidator.revalidate();
} catch (error) {
console.error("Failed to logout:", error);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
{user.success ? (
<div className="flex items-center justify-center flex-col gap-4">
<img src={user.data.picture} className="w-16 h-16 rounded-full shadow-md" alt="Profile" />
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900">Hello, {user.data.name}</h1>
<p className="text-gray-500">{user.data.email}</p>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 mt-2 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all"
>
Logout
</button>
</div>
) : (
// 3. Login Trigger: Direct link to the backend OAuth initialization
<a href="http://localhost:8080/auth/google">
<button className="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all">
<GoogleIcon />
<span className="text-sm font-medium text-gray-700">Continue with Google</span>
</button>
</a>
)}
</div>
);
}
const GoogleIcon = () => (
<svg className="w-5 h-5 mr-3" viewBox="0 0 533.5 544.3" xmlns="http://www.w3.org/2000/svg">
<path fill="#4285F4" d="M533.5 278.4c0-18.5-1.5-36.2-4.3-53.4H272v101.1h146.9c-6.3 34.4-25.7 63.5-54.6 83v68h88.2c51.6-47.6 81-117.5 81-198.7z" />
<path fill="#34A853" d="M272 544.3c73.7 0 135.6-24.3 180.8-66.1l-88.2-68c-24.6 16.5-56.2 26.3-92.6 26.3-71 0-131.2-47.9-152.7-112.2H29.5v70.6C74.8 480.2 167.6 544.3 272 544.3z" />
<path fill="#FBBC05" d="M119.3 323.3c-10.9-32.7-10.9-67.9 0-100.6V152.1H29.5c-39.4 76.9-39.4 168.4 0 245.3l89.8-74.1z" />
<path fill="#EA4335" d="M272 107.7c39.9 0 75.8 13.7 104.1 40.6l78-78C407.6 24 345.7 0 272 0 167.6 0 74.8 64.1 29.5 152.1l89.8 70.6C140.8 155.6 201 107.7 272 107.7z" />
</svg>
);- Trigger: The user clicks "Continue with Google," making a standard HTTP navigation directly to the Fastify backend (
/auth/google). - Handshake: Fastify redirects to Google's consent screen, generating a secure PKCE state.
- Callback: Google redirects back to Fastify (
/auth/google/callback) with an authorization code. - Exchange & Sign: Fastify exchanges the code for tokens, fetches the profile, creates an internal JWT, and sets it as an
HttpOnlycookie. - Session Verification: On any React Router route, the SSR
loaderacts as a proxy, intercepting the cookie from the browser and passing it to Fastify (/api/me) to validate the user before rendering the page.