Overview
When building a modern content platform, one of the most interesting architectural challenges is connecting a Next.js frontend to a headless CMS backend with a smooth, secure authentication experience. In this post, we share how we implemented Google OAuth-based visitor authentication — fully decoupled from the CMS admin system — using a custom JWT flow.
The Two-System Problem
Our stack has two distinct layers:
- Frontend: A Next.js application serving public content (blogs, articles)
- Backend: An ASP.NET Core / Optimizely CMS 12 application managing content and exposing a custom REST API
The CMS has its own built-in admin authentication system (cookie-based). We needed to add a separate visitor authentication — using Google Sign-In — without touching or conflicting with CMS admin sessions. This meant running two auth systems in parallel on the same ASP.NET Identity database.
Architecture at a Glance
βββββββββββββββββββββββββ βββββββββββββββββββββββββββββ
β Next.js Frontend β β CMS Backend (ASP.NET) β
β β β β
β AuthProvider ββββββββββΊβ /api/auth/* β
β (React Context) β JWT β GoogleAuthService β
β β Cookie β JwtService β
β GoogleLoginButton β β β
β UserMenu β β ASP.NET Identity β
β AuthGuard β β (Users + Roles) β
βββββββββββββββββββββββββ βββββββββββββββββββββββββββββ
β β
βββββββββ Google OAuth ββββββββββββ
Authentication Schemes: Three in One
The backend registers three authentication schemes that coexist peacefully:
- Default Cookie Scheme — handled by the CMS framework for admin login. We never touch this.
- Google OAuth Scheme — used only during the OAuth callback flow. A temporary short-lived cookie (
GoogleExternalCookie) passes the Google result to our callback handler. - JWT Bearer Scheme — reads the JWT from an
httpOnlycookie (auth_token) for all visitor-facing API endpoints.
The Login Flow, Step by Step
1. User Clicks "Sign in with Google"
The button calls a simple function that redirects the browser to the backend login endpoint, passing the current page URL as returnUrl:
GET /api/auth/google-login?returnUrl=https://yoursite.com/blog/some-post
2. Backend Challenges Google
The backend issues a Challenge(GoogleDefaults.AuthenticationScheme), redirecting the user to Google's consent screen. The returnUrl is stored in authentication properties.
3. Google Callback
After the user approves, Google redirects back to /api/auth/google-callback. The backend:
- Authenticates the result via the Google scheme
- Calls
FindOrCreateUserAsync()to resolve the visitor account - Issues a JWT token with user claims (name, avatar, roles)
- Sets an
httpOnlycookie (auth_token, 7 days) - Cleans up the temporary OAuth cookie
- Redirects back to the original frontend page
4. Frontend Picks Up the Session
After the redirect, the AuthProvider (React Context) runs on mount and calls GET /api/auth/me with the cookie automatically attached. The backend validates the JWT, fetches the profile, and returns it. The UI updates to show the logged-in state.
User Account Resolution: Find or Create
The FindOrCreateUserAsync method handles three scenarios on every Google login:
- Returning user (linked Google account) — Find by Google provider ID directly. Fastest path.
- Existing account with same email — Auto-link the Google login to the existing account (for non-admin accounts).
- New user — Create a new visitor account, assign the
Visitorsrole, and link the Google login.
Session Lifecycle on the Frontend
The AuthProvider React context manages the entire session lifecycle:
- On mount: Calls
/api/auth/meto restore session from cookie - Every 5 minutes: Calls
/api/auth/status(lightweight JWT check, no DB hit) to detect expired sessions - On expiry: Clears user state, shows a "Session expired" toast notification
- On logout: Calls
POST /api/auth/logout→ backend clears the cookie → frontend clears state
JWT Token Design
The JWT is self-contained and includes:
- User ID (
sub), email, unique token ID (jti) - Display name and avatar URL from Google profile
- User roles (e.g.,
Visitors) - Member since date
The token is delivered and read via an httpOnly cookie — the frontend JavaScript never touches the raw token. The JwtBearer middleware reads it directly from the cookie on every API request.
Route Protection
On the frontend, an AuthGuard component wraps protected pages. It checks the authentication state from context and redirects unauthenticated users to the home page. Loading states are shown while the session is being resolved.
On the backend, any endpoint requiring authentication is decorated with:
[Authorize(AuthenticationSchemes = "JwtBearer")]
This explicitly uses the JWT scheme, completely separate from the CMS admin scheme.
Key Takeaways
- Running two auth systems (CMS admin + visitor Google OAuth) on the same backend is possible by using named authentication schemes in ASP.NET Core
- Using
httpOnlycookies for JWT is a cleaner approach thanlocalStoragefor cross-origin Next.js ↔ CMS setups - Resolving users by Google provider ID first (before email lookup) is more robust for returning users and avoids edge cases with email collisions
- A lightweight
/api/auth/statusendpoint (no DB) keeps background session checks cheap - Storing profile metadata (avatar, display name) as Identity claims avoids extra DB queries on every request