DEV Community

takahiro hashito
takahiro hashito

Posted on

One service account for 21 GA4 and Search Console properties, with zero dependencies

Background

I run 21 small sites and I wanted one command that pulls GA4 (Google Analytics 4) numbers and Search Console numbers for all of them, every night.

Both are reachable through Google APIs, but each site is a separate property. Wire it up naively and you need one authorization per site. Browser-based OAuth is a bad fit for an unattended nightly job: nobody is there to click the consent screen.

So I made one service account (SA) and added it as a viewer on every property. An SA is a Google account for a program rather than a person; if you hold its private key you can mint tokens without a consent screen. Authentication collapsed into a single key file.

This post covers how that collapse was built, and — more importantly — how I misread the numbers it collected.

How it works

Here is the whole path. One key file becomes a JWT (JSON Web Token: a short signed string that asserts "I am this service account"), the JWT is exchanged for an access token, and the same token drives both APIs.

                 ┌───────────────────────┐
  1 SA key ────▶ │ sign an RS256 JWT      │
                 └──────────┬────────────┘
                            │ grant_type=jwt-bearer
                            ▼
                 oauth2.googleapis.com/token
                            │  access_token
             ┌──────────────┴──────────────┐
             ▼                             ▼
   GA4 Data API (runReport)      Search Console API (searchanalytics)
             │                             │
             └──────────────┬──────────────┘
                            ▼
                    per-site JSON (stdout)
Enter fullscreen mode Exit fullscreen mode

No third-party library appears anywhere in that path. I kept dependencies at zero on purpose: googleapis would be easier, but every new machine that runs the nightly job then needs an npm install. Signing a JWT takes nothing but Node's built-in crypto.

Implementation

Getting an access token from the key is the entire trick. The SA JSON holds client_email (the account's address) and private_key (an RSA private key). Below is the part that assembles the JWT and posts it to Google's token endpoint. sa is the parsed key file, now is the current UNIX time in seconds, and b64url is a small Base64URL helper.

const claim = {
  iss: sa.client_email,
  scope: "https://www.googleapis.com/auth/analytics.readonly",
  aud: "https://oauth2.googleapis.com/token",
  iat: now,
  exp: now + 3600,
};
const unsigned = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claim))}`;
const sig = crypto.createSign("RSA-SHA256").update(unsigned).sign(sa.private_key);
const jwt = `${unsigned}.${b64url(sig)}`;

const res = await fetch("https://oauth2.googleapis.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
    assertion: jwt,
  }).toString(),
});
Enter fullscreen mode Exit fullscreen mode

What comes back is an access_token valid for one hour. From there both APIs take the same Authorization: Bearer header, and swapping the scope string is the only change needed to reuse the function for Search Console.

The second change was less glamorous and paid off just as well: one ledger instead of two. The map from site name to numeric GA4 property ID was hardcoded in the fetch script. The same map also lived in the dashboard collector. I diffed all 21 entries and every value matched — but one key was spelled differently (doraemon-tools vs doraemon), and that one site silently failed to resolve.

const PROPERTY_IDS = (() => {
  const { SITES } = require("../dashboard/collect.js");
  const out = {};
  for (const s of SITES) if (s.ga) out[s.key] = String(s.ga);
  return out;
})();
Enter fullscreen mode Exit fullscreen mode

Deriving the map from the collector removed the copy. A side effect: two sites that only existed in the collector's list became fetchable without passing --property by hand.

Gotchas

A missing environment variable wiped out a whole day of measurement. An unattended job does not inherit your interactive shell's environment. GOOGLE_APPLICATION_CREDENTIALS was unset and one night's log was a single line saying GA/SC could not be fetched. I added a fallback to a known key path (~/.config/ga/...). "No env var" and "no key" are different failures.

And the big one: I was reading total sessions as a result.

Some sites had zero rows in their 28-day Search Console query report while GA reported three-digit session counts. Measured on 2026-09-12: blog 146, cve-watch 83, anime-trend 50. If a site has never appeared in search results, then whoever showed up did not arrive through search. They came direct, from a referral, or they were bots. None of that is an SEO result.

The fix was to always pull the channel breakdown (sessionDefaultChannelGroup) and decide reach on the Organic Search figure alone.

function reachVerdict(channels, scImpressions) {
  const organic = Number(channels.organicSearch || 0);
  const total = Number(channels.total || 0);
  const out = {
    organicSearchSessions: organic,
    totalSessions: total,
    nonSearchSessions: Math.max(0, total - organic),
    scImpressions: scImpressions ?? null,
    reached: organic > 0,
  };
  if (organic === 0 && total > 0) {
    out.verdict = "not-reached";
  } else if (organic > 0) {
    out.verdict = "reached";
  } else {
    out.verdict = "no-sessions";
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Three states, not two: not-reached (people arrive, but not from search), reached (search delivers), and no-sessions (nobody came at all). The old code folded the first and the last into a single "has sessions / has none" test, which is exactly how not-reached got read as success.

One detail I care about: when the Search Console figure cannot be fetched, it is not filled in with 0.

const sc = scImpressions === undefined || scImpressions === null
  ? null
  : Number(scImpressions);
Enter fullscreen mode Exit fullscreen mode

A missing measurement and a measured zero are different facts. Writing 0 turns a day when the API was down into a day with zero impressions, and a week later that ledger shows a decline that never happened. It stays null, and the verdict text says so explicitly.

The result

A site that runs on this: https://hashitosystem.com

Wrap-up

  • One SA added to every property reduces unattended auth to a single key file. Signing the JWT needs only Node's crypto, so the dependency count stays at zero
  • Two copies of the same lookup table will drift in spelling even when every value matches. Derive it from one source
  • A session count is not evidence that search brought anyone. Pull the channel breakdown and judge on Organic Search
  • Never store a failed fetch as 0. Once a gap and a real zero share a type, you cannot tell them apart later

This article is about my own side project. It was written with AI assistance.

Top comments (0)