p4ni.

Tutorial

Query the GA4 Data API from a Cloudflare Worker (No Google SDK)

· Updated · 10 min read

On this page

Once a month I want five numbers and a list of top pages out of GA4: page views, active users, total users, sessions, and how many people clicked through to the theme I sell. Getting them meant opening the dashboard, setting a date range, and reading values off a chart — a few minutes of clicking that produces something I then have to type somewhere else. The obvious fix is an HTTP endpoint that returns those numbers as JSON, so the monthly check can fetch them instead of me.

The naive version of that is a local script holding a service-account key. I didn’t want a private key sitting in a file on my laptop for a job that runs twelve times a year. A Cloudflare Worker solves that neatly: the key lives in a secret, the Worker is the only thing that can read it, and I get a URL.

The part that isn’t neat is authentication. Google’s client libraries assume Node, and Workers isn’t Node. It runs on workerd, a different runtime.

The SDK isn’t the path

google-auth-library is the package that would normally handle this. Installed on its own:

$ npm i google-auth-library
added 23 packages
$ du -sh node_modules
 12M	node_modules

Twelve megabytes and 23 packages to sign one JWT. Bundle size is a real constraint on Workers, but the harder problem shows up before that. Point esbuild at it with the workerd condition and it stops:

$ npx esbuild entry.mjs --bundle --format=esm --conditions=workerd,worker,browser
✘ [ERROR] Could not resolve "stream"
    node_modules/gaxios/build/cjs/src/gaxios.js:24:25
✘ [ERROR] Could not resolve "crypto"
    node_modules/gaxios/build/cjs/src/gaxios.js:26:80
...
6 of 70 errors shown

Seventy unresolved imports, all of them Node built-ins, and they don’t all come from the HTTP layer: 27 are google-auth-library itself reaching for fs, os, and child_process, 11 are the jws/jwa pair that signs the JWT, and the rest are node-fetch and proxy agents. Turning on nodejs_compat gives you polyfills for most of that, and you may well be able to force it through. I stopped and asked what the library was for.

It does one thing I need: take a service-account key, produce an access token. That exchange is a documented HTTP flow with one piece of cryptography in it — an RS256 signature — and crypto.subtle does RS256. So the dependency count for this Worker is zero.

The flow, in four steps

The JWT bearer grant works like this:

  1. Build a JSON header and a claim set naming the service account, the scope, and the token endpoint as the audience.
  2. Sign base64url(header).base64url(claims) with the service account’s private key, RS256.
  3. POST that JWT to https://oauth2.googleapis.com/token as an assertion, with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer.
  4. Get back an access token good for an hour. Use it as a bearer token against the API.

You are signing an assertion that says “I am this service account, and I want a token for this scope.” The private key is the proof.

Signing the JWT with Web Crypto

Two conversions do most of the work. crypto.subtle.importKey wants a pkcs8 key as an ArrayBuffer, and the key in a service-account JSON is a PEM string — base64 with a header, footer, and line breaks. And JWTs use base64url, which btoa doesn’t emit.

const SCOPE = 'https://www.googleapis.com/auth/analytics.readonly';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';

function b64url(bytes) {
  let s = typeof bytes === 'string' ? bytes : String.fromCharCode(...new Uint8Array(bytes));
  return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function pemToDer(pem) {
  const body = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
  const bin = atob(body);
  const der = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) der[i] = bin.charCodeAt(i);
  return der.buffer;
}

async function getAccessToken(sa) {
  const now = Math.floor(Date.now() / 1000);
  const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const claims = b64url(
    JSON.stringify({
      iss: sa.client_email,
      scope: SCOPE,
      aud: TOKEN_URL,
      iat: now,
      exp: now + 3600,
    })
  );
  const key = await crypto.subtle.importKey(
    'pkcs8',
    pemToDer(sa.private_key),
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const sig = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    key,
    new TextEncoder().encode(`${header}.${claims}`)
  );
  const jwt = `${header}.${claims}.${b64url(sig)}`;

  const res = await fetch(TOKEN_URL, {
    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,
    }),
  });
  if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
  return (await res.json()).access_token;
}

Three details in there are worth naming, because each one is a plausible way to lose an afternoon:

RSASSA-PKCS1-v1_5 is RS256. JWT algorithm names and Web Crypto algorithm names don’t match, and the mismatch fails in the least helpful way: RSA-PSS also signs with an RSA key and SHA-256 and produces a signature of the right length, so nothing goes wrong until the token endpoint refuses an assertion it can’t verify — a rejection that says nothing about which algorithm it expected.

The spread in b64url is safe here, and only here. String.fromCharCode(...bytes) passes every byte as an argument, and there’s an engine limit on argument count. An RS256 signature from a 2048-bit key is 256 bytes, so it’s nowhere near the ceiling. Copy that helper into something that base64s a response body and it will fail on large inputs.

exp is the JWT’s lifetime, not the token’s. An hour is the maximum Google accepts, and the assertion is spent the moment it’s exchanged. What you get back has its own hour-long expiry.

Getting the key into the Worker

Put the whole service-account JSON in one secret, not the fields separately:

wrangler secret put GA4_SA_KEY < service-account.json

Then JSON.parse(env.GA4_SA_KEY) in the Worker. The reason to do it this way is private_key: inside the JSON it’s a single line with literal \n escapes, and JSON.parse turns those back into real newlines. Pull the PEM out and paste it in as its own secret and you’re hand-managing multi-line text through a CLI prompt, which is exactly where the newlines get mangled and pemToDer starts throwing.

Delete the local copy of the JSON afterwards. Worker secrets are write-only — you can overwrite one or delete it, but you can’t read it back out of Cloudflare, which is the property that makes this better than a file on a laptop. If you lose the key you issue a new one in GCP; there’s nothing to recover.

A key on its own still returns 403

Creating the service account and the key gets you nothing on its own. Two more steps, and forgetting either one gives you a 403 that reads like a bug:

  • Enable the Google Analytics Data API in the GCP project. A service account can only call APIs the project has turned on.
  • Add the service account’s email as a user on the GA4 property itself. This is the one that catches people. GA4 property access is managed in Analytics, not in GCP, so a key with perfectly good credentials has no idea your property exists until you paste something@project.iam.gserviceaccount.com into the property’s access management screen. Viewer is the minimum role that can read reports, and it’s the right one here — I granted mine Marketer, which is more than a read-only endpoint needs.

Calling runReport

With a token, the Data API is ordinary JSON over HTTP:

const API = 'https://analyticsdata.googleapis.com/v1beta';

async function runReport(token, property, body) {
  const res = await fetch(`${API}/${property}:runReport`, {
    method: 'POST',
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`runReport failed: ${res.status} ${await res.text()}`);
  return res.json();
}

One GA4 property covers every subdomain here — this blog and Almanac, the theme’s demo — so every report has to be filtered by hostname or the numbers are meaningless. I put hostName in both dimensionFilter and dimensions. That isn’t an API constraint — the documented examples filter on dimensions they never request — it just makes the scoping visible when I look at a raw response:

const hostFilter = {
  dimensionFilter: {
    filter: { fieldName: 'hostName', stringFilter: { value: 'astro.p4ni.com' } },
  },
};

const totals = await runReport(token, property, {
  dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }],
  dimensions: [{ name: 'hostName' }], // not for the output — to see the scoping
  metrics: [
    { name: 'screenPageViews' },
    { name: 'activeUsers' },
    { name: 'totalUsers' },
    { name: 'sessions' },
  ],
  ...hostFilter,
});

Adding a dimension normally splits your totals across rows, which is the opposite of what a totals report wants. It’s harmless here precisely because the filter narrows it to one value: one host, one row. If you ever pass host=all to skip the filter, drop the dimension too, or you’ll get a row per subdomain and read the first one as the total.

The endpoint runs three reports — totals, top pages, and the gumroad_click event count — and they don’t depend on each other, so they go out together:

const [totals, pages, gumroad] = await Promise.all([...]);

Date handling is one thing the API makes easy: startDate and endDate accept 30daysAgo, yesterday, and today alongside YYYY-MM-DD, so the Worker passes query parameters straight through without parsing dates.

Locking the endpoint

A workers.dev subdomain is public, and this one returns my analytics to anyone who finds it. So every request needs a bearer token of my own, checked before anything else happens:

const auth = request.headers.get('authorization') || '';
if (!env.AUTH_TOKEN || auth !== `Bearer ${env.AUTH_TOKEN}`) {
  return new Response('unauthorized', { status: 401 });
}

The !env.AUTH_TOKEN half matters as much as the comparison. Without it, a deploy that forgot the secret would compare '' against 'Bearer ' — close enough to worry about, and the kind of thing that turns a missing secret into an open endpoint instead of a broken one. Fail closed.

$ curl -s -o /dev/null -w "%{http_code}\n" https://<worker>.workers.dev/
401
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer nope" https://<worker>.workers.dev/
401

What comes back

{
  "range": { "start": "2026-07-01", "end": "today" },
  "host": "astro.p4ni.com",
  "pageViews": 18,
  "activeUsers": 5,
  "totalUsers": 5,
  "sessions": 5,
  "gumroadClicks": 1,
  "topPages": [
    { "path": "/", "views": 9 },
    { "path": "/themes/", "views": 3 },
    { "path": "/about/", "views": 1 },
    { "path": "/blog/deploy-astro-to-cloudflare-workers/", "views": 1 }
  ]
}

Those are real numbers from a blog that had been live for three days, and they’re the reason the endpoint is worth having at this size: it costs nothing to call, so the monthly check reads it whether the number is 18 or 18,000. Note that gumroad_click is a custom event — the Data API will happily return 0 for an event name that doesn’t exist, so if a metric flatlines, check the event name before you conclude nobody clicked.

That’s also the failure mode to watch for at the collection end. Analytics needs three separate CSP directives to work — script-src for gtag.js, connect-src for the beacons, img-src for the pixel fallback — and a policy that only allows the first breaks measurement while the page looks completely fine.

What I left undone

No token caching. Every request does a full token exchange before it touches the Data API, which is why a call takes 1.3–1.8 seconds end to end. Access tokens are valid for an hour and I call this once a month, so caching would optimize something that happens twelve times a year. If you’re calling it from a dashboard that refreshes, the Cache API is the cheap fix — but only on a custom domain. On workers.dev, caches.default.put() stores nothing, because the cache belongs to a zone and workers.dev has none:

// swap example.com for a domain you control
const cacheKey = new Request('https://cache.example.com/ga4-token');
const cache = caches.default;
let token = await cache.match(cacheKey).then((r) => r?.text());
if (!token) {
  token = await getAccessToken(JSON.parse(env.GA4_SA_KEY));
  await cache.put(
    cacheKey,
    new Response(token, { headers: { 'cache-control': 'max-age=3000' } })
  );
}

max-age=3000 rather than 3600 leaves ten minutes of slack, so a token fetched from cache at the last second doesn’t expire mid-flight. Cache misses across colos just mean an occasional extra exchange. Be deliberate about the cache key, though: you’re putting a credential into storage that isn’t private to this Worker, so use a hostname you control and treat the cached token as something that could be read by anything else running on the same zone.

No rate limiting, one token. A single static bearer with no rotation schedule. For an endpoint whose entire audience is me, on a URL nobody has, that’s a deliberate stopping point rather than a finished design — worth naming, since the security of the whole thing rests on that one string.

Recap

  • Google’s auth library doesn’t bundle for workerd — 70 unresolved Node imports before you reach the 12 MB question. The flow underneath it is one signature and one POST.
  • RS256 is RSASSA-PKCS1-v1_5 with SHA-256 in Web Crypto. RSA-PSS looks equally correct and fails with a generic error.
  • Store the whole service-account JSON in one secret and JSON.parse it. That’s what keeps the PEM’s newlines intact.
  • A key from GCP still can’t read your property. Enable the Data API in the project, then add the service account’s email to GA4’s own access management as Viewer. Forget either one and you get the same 403, with nothing to say which is missing.
  • Check your own auth header before anything else, and treat a missing secret as a closed door rather than an empty string.