Rolling a Google Service Account JWT in Node.js without the googleapis package
Zero-Dependency Google Auth: Implementing RFC 7523 JWT Bearer Grants in Vanilla Node.js
Current Situation Analysis
Modern CI/CD pipelines and edge functions frequently interact with Google APIs for tasks like SEO verification, sitemap pinging, or analytics aggregation. The default approach is to install the googleapis npm package. While comprehensive, this package introduces approximately 380KB of bundle size and pulls in over 450 transitive dependencies.
For a CI script that only needs to call a single endpoint—such as the Search Console URL Inspection API—this dependency footprint is disproportionate. It increases build times, expands the attack surface for supply chain vulnerabilities, and complicates container images.
The core misunderstanding is that developers assume the SDK is required to handle the authentication handshake. In reality, Google's service account authentication follows RFC 7523 (JWT Bearer Grant profile of OAuth 2.0). This is a standardized, stateless flow that can be implemented with Node.js built-in modules (crypto, fetch, URL) in fewer than 60 lines of code, adding zero external dependencies.
WOW Moment: Key Findings
Replacing the SDK with a native implementation yields immediate gains in efficiency and security posture without sacrificing functionality. The following comparison highlights the impact for single-API use cases:
| Approach | Bundle Size | Transitive Dependencies | Supply Chain Risk | Auth Latency |
|---|---|---|---|---|
googleapis SDK |
~380 KB | 450+ | High (450+ vectors) | Low (cached) |
| Native RFC 7523 | ~0 KB | 0 | None | Low |
Why this matters:
Eliminating 450 dependencies reduces the cognitive load of dependency auditing and removes the risk of a compromised sub-dependency halting your pipeline. Furthermore, the native implementation is transparent; you can audit every byte of the auth flow, making debugging invalid_grant errors significantly faster than parsing through SDK abstractions.
Core Solution
The implementation relies on constructing a signed JWT, exchanging it for a short-lived access token, and using that token to call the target API.
1. Architecture Decisions
- Scope Selection: The URL Inspection API requires the
webmasters.readonlyscope. A common trap is using the newersearchconsolescope, which does not grant access to this legacy endpoint. - Signing Algorithm: Google requires
RS256. Node'scrypto.createSignhandles this natively using the PKCS#8 private key provided in the service account JSON. - Base64url Encoding: JWTs require Base64url encoding, which differs from standard Base64 by stripping padding (
=) and replacing+with-and/with_. - Token Reuse: The access token is valid for 3600 seconds. For scripts checking multiple URLs, cache the token and reuse it until expiration rather than fetching a new token per request.
2. Implementation
The following TypeScript implementation uses a functional pipeline. It separates concerns into payload construction, signing, token exchange, and API invocation.
import { createSign } from 'node:crypto';
// --- Types ---
interface ServiceAccountCredentials {
client_email: string;
private_key: string;
}
interface JwtPayload {
iss: string;
scope: string;
aud: string;
iat: number;
exp: number;
}
interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
}
// --- Constants ---
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const INSPECTION_API_URL = 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect';
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
// --- Helpers ---
const toBase64Url = (data: string | object): string => {
const json = typeof data === 'string' ? data : JSON.stringify(data);
return Buffer.from(json)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
};
// --- Core Functions ---
/**
* Constructs the JWT payload according to RFC 7523.
*/
const buildJwtPayload = (creds: ServiceAccountCredentials): JwtPayload => {
const now = Math.floor(Date.now() / 1000);
return {
iss: creds.client_email,
scope: REQUIRED_SCOPE,
aud: GOOGLE_TOKEN_URL,
iat: now,
exp: now + 3600,
};
};
/**
* Signs the JWT using the service account private key.
*/
const signJwt = (payload: JwtPayload, privateKey: string): string => {
const header = toBase64Url({ alg: 'RS256', typ: 'JWT' });
const body = toBase64Url(payload);
const unsigned = `${header}.${body}`;
const signature = createSign('RSA-SHA256')
.update(unsigned)
.end()
.sign(privateKey, 'base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
return `${unsigned}.${signature}`;
};
/**
* Exchanges the signed JWT assertion for an OAuth2 access token.
*/
const exchangeJwtForToken = async (assertion: string): Promise<TokenResponse> => {
const formBody = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
});
const response = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formBody,
});
if (!response.ok) {
const errorText = await response.text();
// Raw error text often contains critical details like "Token must expire within 3600 seconds"
throw new Error(`Token exchange failed [${response.status}]: ${errorText.slice(0, 300)}`);
}
return response.json() as Promise<TokenResponse>;
};
/**
* Calls the URL Inspection API with the obtained access token.
*/
const inspectUrl = async (
accessToken: string,
targetUrl: string,
verifiedSiteUrl: string
): Promise<any> => {
const payload = {
inspectionUrl: targetUrl,
siteUrl: verifiedSiteUrl,
};
const response = await fetch(INSPECTION_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Inspection API failed [${response.status}]: ${err}`);
}
return response.json();
};
// --- Usage Example ---
export const runInspection = async (
creds: ServiceAccountCredentials,
urlToCheck: string,
siteProperty: string
) => {
// 1. Build and sign JWT
const payload = buildJwtPayload(creds);
const assertion = signJwt(payload, creds.private_key);
// 2. Get Access Token
const { access_token } = await exchangeJwtForToken(assertion);
// 3. Call API
const result = await inspectUrl(access_token, urlToCheck, siteProperty);
return {
url: urlToCheck,
coverage: result.inspectionResult?.indexStatusResult?.coverageState,
lastCrawl: result.inspectionResult?.indexStatusResult?.lastCrawlTime,
};
};
3. Rationale
webmastersvssearchconsole: The code explicitly useswebmasters.readonly. If you usesearchconsole, the API will return permission errors for the Inspection endpoint. This is a documented quirk of Google's API versioning.- Error Transparency: The
exchangeJwtForTokenfunction captures the raw response body on failure. Google'sinvalid_granterrors often include anerror_descriptionfield that pinpoints the exact issue (e.g., clock skew, missing permissions). SDKs often swallow these details. - PKCS#8 Compatibility: The
private_keyfrom the service account JSON is already in PKCS#8 PEM format.createSignaccepts this directly; no conversion libraries are needed.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|---|---|
| Scope Mismatch | Using searchconsole scope for URL Inspection. |
Always use webmasters.readonly for the Inspection API. |
| Site URL Format | siteUrl does not match the verified property exactly. |
Ensure siteUrl includes the trailing slash if registered that way. Exact string match is required. |
| Missing Permissions | Service account exists but lacks GSC access. | Add the service account's client_email as an Owner or Full User in Search Console settings. |
| Clock Skew | iat and exp claims drift due to unsynced system time. |
Ensure the host machine's clock is synchronized via NTP. |
| Base64url Padding | Failing to strip = padding or replace +//. |
Use a dedicated encoder that strips padding and substitutes characters. |
| Token Per Request | Fetching a new token for every URL inspection. | Cache the token and reuse it for up to 3600 seconds. |
| Error Obscurity | Swallowing API errors without logging the body. | Log the raw response text on non-200 status codes to capture error_description. |
Production Bundle
Action Checklist
- Verify Scope: Confirm your script uses
webmasters.readonly, notsearchconsole. - Check Permissions: Ensure the service account email is added to Google Search Console with Owner/Full access.
- Validate Site URL: Verify the
siteUrlstring matches your GSC property exactly, including protocol and trailing slash. - Sync Clock: Ensure the deployment environment has accurate time synchronization.
- Implement Caching: Store the access token and reuse it until
expto reduce latency and API calls. - Secure Secrets: Store
private_keyandclient_emailin CI secrets, never in source code. - Test Error Paths: Simulate a 401/403 to verify your error logging captures the raw Google response.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| CI Script for Single API | Native RFC 7523 | Zero deps, fast cold start, minimal bundle. | Reduces build time and dependency audit overhead. |
| Web App with Multiple APIs | googleapis SDK |
Unified auth, type safety, batching support. | Higher bundle size justified by feature set. |
| Long-Running Worker | googleapis SDK |
Automatic token refresh and retry logic built-in. | Prevents auth failures during extended runs. |
| Edge Function / Serverless | Native RFC 7523 | Minimal cold start latency, small footprint. | Optimizes execution time and memory usage. |
Configuration Template
Use environment variables to inject credentials securely. This template assumes a standard .env setup.
# .env
GOOGLE_SA_CLIENT_EMAIL=your-sa@project-id.iam.gserviceaccount.com
GOOGLE_SA_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQ...\n-----END PRIVATE KEY-----\n"
GSC_VERIFIED_SITE_URL=https://www.example.com/
// config.ts
export const config = {
credentials: {
client_email: process.env.GOOGLE_SA_CLIENT_EMAIL!,
private_key: process.env.GOOGLE_SA_PRIVATE_KEY!,
},
siteUrl: process.env.GSC_VERIFIED_SITE_URL!,
};
Quick Start Guide
- Create Service Account: In Google Cloud Console, create a service account and download the JSON key.
- Grant Access: Open Google Search Console, go to Settings > Users and Permissions, and add the service account email as an Owner.
- Copy Code: Paste the
runInspectionfunction and helpers into your script. - Set Env Vars: Export
GOOGLE_SA_CLIENT_EMAIL,GOOGLE_SA_PRIVATE_KEY, andGSC_VERIFIED_SITE_URL. - Run: Execute your script. Verify the output contains
coverageStateandlastCrawlTime.
