Architectural problem: identity configuration before code#
In any OAuth 2.0 authentication implementation, there is a layer that must be provisioned correctly before any code is written: the application's identity with the identity provider (in this case, Google). Without this step, any authentication library (NextAuth.js, Auth.js v5, Passport.js, Lucia) fails with redirect_uri_mismatch or access_denied errors before a single line of backend code is executed.
OAuth 2.0 operates under the authorization delegation protocol. The simplified flow:
User clicks "Login with Google"
│
▼
App redirects to accounts.google.com/o/oauth2/v2/auth
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/api/auth/callback/google
&scope=openid profile email
&response_type=code
│
▼
Google validates:
1. Does YOUR_CLIENT_ID exist?
2. Is redirect_uri in the list of authorized URIs?
3. Are the requested scopes approved?
│
✅ All valid → Google authenticates the user and returns the authorization code
❌ Any validation fails → error: redirect_uri_mismatch / access_denied
The goal of Google Cloud Console configuration: register the application, define which data will be accessed, and explicitly authorize which origins and callback URIs are legitimate.
1) Introduction and delegation of authority#
The OAuth 2.0 protocol is the industry standard for secure delegation of authority on the Web. It allows a client application to request restricted access to a user's profile data (hosted by an Identity Provider, such as Google) without requiring the user to share their primary credentials (passwords) with the application. In practical terms of software architecture, the success of this ecosystem relies on precise configuration within the provider's console. The slightest mismatch in domain names, callback paths, or scope permissions will cause catastrophic failures that disrupt the onboarding flow for users.
2) Project provisioning in Google cloud console#
Google Cloud organizes all development resources and APIs into isolated projects. It is a best infrastructure practice to isolate corporate environments so that testing or staging credentials do not share the same privileges as production.
To instantiate the environment:
- Log in to the Google Cloud Console.
- In the top navigation bar, click the project selector and choose New Project.
- Fill in a descriptive name (e.g.,
myapp-production-auth) and associate it with a Google Workspace organization if applicable. - Click Create and wait for the internal provisioning of the container to complete.
3) OAuth consent screen: core setup#
The OAuth Consent Screen is the interface the end-user sees when they agree to share their data with your system.
3.1 user type#
- Internal: Restricts login only to email accounts belonging to the same Google Workspace organization as the project. It does not require a formal Google verification process.
- External: Allows any Google email account (Gmail or external Workspace) to log in. Requires verification before entering public production mode.
3.2 core scopes#
Add exactly the three essential scopes recommended by the OpenID Connect (OIDC) standard for user identification:
openid: For issuing the JWT ID Token..../auth/userinfo.email: For reading the primary email address..../auth/userinfo.profile: For obtaining name and avatar.
3.3 test users#
While the project is in Testing mode (unverified), only emails explicitly added to the Test Users list will be able to log in. External accounts not listed will receive an HTTP 403 access_denied error.
4) Credentials creation and setup#
Navigate to Credentials -> Create Credentials -> OAuth client ID.
4.1 authorized javascript origins#
The origin domain that initiates the HTTP request.
- Development:
http://localhost:3000 - Production:
https://mydomain.com - Strict Rule: Never include a trailing slash or subfolder paths in these URLs.
4.2 authorized redirect URIs#
The full path of the endpoint (callback) that will process the authorization code.
- For NextAuth.js (App Router):
http://localhost:3000/api/auth/callback/googleandhttps://mydomain.com/api/auth/callback/google.
5) Secrets isolation and environment setup#
The data generated by Google Cloud consists of a Client ID (public identifier) and a Client Secret (private cryptographic secret). Leaking the Client Secret allows attackers to spoof requests on behalf of your application.
- Store credentials exclusively in the local
.env.localfile:
GOOGLE_CLIENT_ID=123456789-abcdef.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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er_value_xxxxxx
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=generated_with_openssl_rand_base64
- Make sure the
.gitignorefile blocks environment variables files from being committed to the git repository:
# .gitignore
.env
.env.local
.env.*.local
*.env
6) Post-configuration identity verification#
After registering the keys in the Google Cloud Console, it is essential to perform tests to validate the consistency and syntax of the generated Client ID:
- Test the status and integrity of the Client ID key by sending a direct call to Google's public token validator:
curl -s "https://oauth2.googleapis.com/tokeninfo?id_token=TEST" | jq .
- Validate the flow by simulating local access to the authentication endpoint of your Next.js server:
- Open your browser and access the local route
http://localhost:3000/api/auth/signin/google. - Confirm that the server initiates the redirect to
https://accounts.google.com/o/oauth2/v2/auth. - Check the browser's address bar to ensure that the
client_idandredirect_uriparameters match exactly what is registered in the cloud console.
7) Refresh token configuration#
To maintain a fluid user experience and avoid frequent disconnections each time the access token expires, configure NextAuth to request a refresh token during the initial consent cycle.
In the src/auth.ts file, adjust the Google provider by adding the offline and consent authorization parameters:
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
access_type: "offline", // Requires refresh token from Google
prompt: "consent", // Forces screen display to capture refresh_token
},
},
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.expiresAt = account.expires_at // Expiry timestamp in seconds
}
return token
},
},
})
8) Token expiration handling and renewal#
The access token issued by Google expires by default in 3600 seconds (1 hour). To ensure the persistence of active sessions without forcing manual re-authentication, check and renew the access token on the server using the refresh token:
- Utility function to check token expiration and handle dynamic renewal:
// Validate token expiration based on date/time
const isTokenExpired = (token: any) => {
return Date.now() >= (token.expiresAt * 1000)
}
// Renew access token with an API call to Google OAuth2
const refreshAccessToken = async (token: any) => {
try {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
refresh_token: token.refreshToken,
grant_type: "refresh_token",
}),
})
const data = await response.json()
if (!response.ok) throw data
return {
...token,
accessToken: data.access_token,
expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
// If Google does not return a new refresh_token, keep the current one
refreshToken: data.refresh_token ?? token.refreshToken,
}
} catch (error) {
console.error("Failed to refresh Google access token:", error)
return { ...token, error: "RefreshAccessTokenError" }
}
}
9) Secure logout flow implementation#
Ending a session should clear local traces of authentication in NextAuth and optionally guide the user in removing the token with the identity provider.
Add secure logout control to your frontend application interface:
import { signOut } from "next-auth/react"
// Strategy 1: Local logout clearing application cookies
const handleLocalLogout = () => {
signOut({ redirect: true, callbackUrl: "/" })
}
// Strategy 2: Combined logout clearing Google session
const handleGlobalLogout = () => {
// Redirects to Google logout and returns to the site after cleanup
signOut({
callbackUrl: "https://accounts.google.com/Logout",
})
}
10) CORS policies and gateway rate limiting#
Next.js authentication API routes must be protected against brute force attacks, concurrent request floods, and malicious requests originating from unauthorized domains:
- Configure CORS security header directives for the authentication API route in the
next.config.jsfile:
module.exports = {
async headers() {
return [
{
source: "/api/auth/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: process.env.NEXTAUTH_URL || "*" },
{ key: "Access-Control-Allow-Methods", value: "GET, POST, OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" },
],
},
]
},
}
- Implement basic rate limiting based on the source IP address in the
middleware.tsfile to shield login attempts:
import { NextResponse } from "next/server"
import type { NextRequest } from "next/request"
const rateLimitMap = new Map<string, number[]>()
export function middleware(request: NextRequest) {
const ip = request.ip || "127.0.0.1"
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15-minute window
const maxRequests = 20
if (request.nextUrl.pathname.startsWith("/api/auth/signin")) {
if (!rateLimitMap.has(ip)) {
rateLimitMap.set(ip, [])
}
const requests = rateLimitMap.get(ip)!
const recentRequests = requests.filter(time => time > now - windowMs)
if (recentRequests.length >= maxRequests) {
return NextResponse.json({ error: "Limit of attempts exceeded. Try again later." }, { status: 429 })
}
recentRequests.push(now)
rateLimitMap.set(ip, recentRequests)
}
return NextResponse.next()
}
11) Ssl/tls, domain, and privacy policy audit#
For OAuth 2.0 to function correctly in production, all involved endpoints must run under secure HTTPS connections with valid digital certificates and a verified domain.
- Test the SSL status and verify the expiration dates of the digital certificate on the application's primary server:
echo | openssl s_client -connect mydomain.com:443 2>/dev/null | openssl x509 -noout -dates
- Validate the domain ownership verification status by querying the TXT record associated with Google Cloud in the public DNS:
dig TXT _google-domain-verification.mydomain.com +short
- Make sure the privacy policy (required by the Google console to leave Testing mode) is publicly accessible via HTTPS:
curl -I https://mydomain.com/privacy
12) Audit logging and auth tracking#
Logging events in the login cycle assists in forensic auditing of system behavior and early detection of suspicious access.
Implement an audit callback in src/auth.ts:
const logAuthAttempt = (email: string, success: boolean, provider: string) => {
console.log(JSON.stringify({
event: "oauth_auth_attempt",
email,
success,
provider,
timestamp: new Date().toISOString(),
}))
}
// Inside the NextAuth configuration:
callbacks: {
async signIn({ user, account, profile }) {
if (profile?.email) {
logAuthAttempt(profile.email, true, account?.provider || "unknown")
}
return true
}
}
13) Configuration checklists and risk matrix#
13.1 operational checklist for Google oauth2 integration#
Make sure to validate all listed items before launch:
- [ ] Project created and isolated in the Google Cloud Console with billing active.
- [ ] OAuth Consent Screen configured as
Externalwithopenid,email, andprofilescopes. - [ ] Real Privacy Policy and Terms of Use links public and accessible via HTTPS.
- [ ] Test users manually added to the Test Users list in the console.
- [ ] OAuth Client ID credentials generated with application type
Web application. - [ ] Endpoints and JavaScript origins configured without trailing slashes and additional paths.
- [ ] Keys saved exclusively in the local
.env.localfile and hidden in.gitignore. - [ ] Refresh Tokens and token expiration renewal logic active on the server.
- [ ] CORS headers and active Rate Limit in the
middleware.tsfile. - [ ] Structured audit logs inserted in the authentication callbacks.
13.2 authentication risk matrix and severity#
| Risk / Threat | Severity | Description | Applied Technical Mitigation Action |
|---|---|---|---|
| Secret Leak | Critical | Private key Client Secret exposed publicly in public Git repositories. | Storing keys in secure environment variables and rigid restrictions in .gitignore. |
| Account Blocks | High | Users unable to log in, receiving 403 access_denied error in staging. | Manually adding email addresses of testers in the Test Users tab of the Google Cloud console. |
| Callback Invalidation | High | redirect_uri_mismatch error blocks flow due to port or path discrepancy. | Detailed registration and exact binary matching of registered callback URIs. |
| Brute Force Attacks | Medium | Excessive login request attempts and callback API flooding. | Middleware configuration with active Rate Limit based on IP and a 15-minute request window. |
| Renewal Failure | Medium | User abruptly disconnected after 1 hour due to expired Google access token. | Configuring offline access parameters and dynamic automatic renewal using refresh tokens. |
Technical conclusion#
Integrating Google OAuth 2.0 with NextAuth.js is more than writing route code; it requires solid credential governance and network security. By isolating secrets from the repository, monitoring token expiration times, and auditing end-to-end secure HTTPS connections, you build a resilient authentication barrier in compliance with industry cybersecurity best practices.
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.



Comments
Join the discussion below.
0 comments