An ornate antique iron key with a pierced bow, photographed on white

Guide

Service account access to GA4 and Search Console

Grant a service account read access to GA4 and Search Console without any IAM roles: the exact console steps, the private-key gotcha, and a check script.

Published August 8, 2026

A service account can read GA4 and Search Console with zero IAM roles. Create it in Google Cloud, enable the two APIs, then grant its email address access inside each product: Viewer in GA4's property access screen, Restricted in Search Console's users screen. The project itself grants nothing — the products do.

Why does the service account need no IAM roles?

This is the single most common point of confusion in wiring these APIs. IAM roles govern Google Cloud resources — buckets, VMs, BigQuery datasets. GA4 properties and Search Console properties are not Cloud resources; each has its own access list, and the APIs check that list. So the service account is created role-less, and a service account email gets added to each product the way you'd add a human teammate. Granting Editor on the project — the reflex fix — changes nothing about a 403 from either API.

How do you set it up, step by step?

  1. In the Cloud console, create (or pick) a project. Its only job is to hold the API enablement and the service account.
  2. APIs & Services → Library: enable the Google Analytics Data API and the Google Search Console API.
  3. IAM & Admin → Service Accounts → Create. Name it, and when the wizard offers roles, skip that step.
  4. On the new account: Keys → Add key → JSON. This downloads the only copy of the private key — handle it like a password (next section).
  5. In GA4: Admin → Property access management → +, paste the service account email, role Viewer. Google documents the screen in Add, edit, and delete users. Grant on the property, not the account, unless you mean every property.
  6. In Search Console: Settings → Users and permissions → Add user, same email, permission Restricted — read access is all an API reader needs. Reference: Managing owners, users, and permissions.

Scopes at request time stay read-only to match: …/auth/analytics.readonly and …/auth/webmasters.readonly.

How do you store the private key without corrupting it?

The JSON key contains a multi-line PEM, and multi-line values do not survive env-var round trips intact. Paste it raw into a deploy dashboard and the newlines arrive as literal \n; the first signature attempt then fails with the famously unhelpful error:1E08010C:DECODER routines::unsupported. Two working conventions, used in two systems behind this post:

  • Store it escaped and un-escape on read: key.replace(/\\n/g, "\n"). Fine, but the failure when someone pastes it differently is still the decoder error.
  • Base64 the whole PEM and decode on read — with a guard that turns the cryptic failure into an instruction:
The base64 convention, guard included
# Extract just the PEM and base64 it — then delete the key file.
cat sa.json | jq -r .private_key | base64
rm sa.json

# In code: decode, then fail loudly if it isn't a PEM
const key = Buffer.from(process.env.GOOGLE_SA_PRIVATE_KEY_B64, "base64")
  .toString("utf8")
if (!key.includes("BEGIN") || !key.includes("PRIVATE KEY")) {
  throw new Error("GOOGLE_SA_PRIVATE_KEY_B64 did not decode to a PEM key")
}

Either way: the downloaded sa.json gets deleted once its two values are in the env file, and the gitignore gets a backstop entry for it — a key file that never exists in the repo can't be committed to it.

How do you verify access before writing any real code?

Between "key works" and "reports flow" sit four distinct failures — API not enabled, no grant, wrong property id, bad key — and they all surface as terse HTTP errors. Both systems behind this post ship a check command that mints a token, probes each API, and translates the response into the fix:

Translating probe failures into instructions
if (/SERVICE_DISABLED|has not been used in project/i.test(body)) {
  const api = body.match(/([a-z]+\.googleapis\.com)/)?.[1] ?? "the API"
  return `not enabled — turn on ${api} in APIs & Services → Library`
}
if (status === 403) return "permission denied — the robot has no grant yet"
if (status === 401) return "credential rejected — check the key in .env.local"
  1. Mint a token (any Google auth library, or the raw JWT-bearer flow against oauth2.googleapis.com/token).
  2. List what the robot can see: accountSummaries on the Analytics Admin API, and the sites list on the Search Console API. An empty list means the grant is missing — a much better answer than a bare 403.
  3. Only then run a real report query. Configure property ids from what the probe printed, never from memory.

With access verified, the actual queries are the easy part: pulling GA4 reports and querying Search Console.

Questions

Does a service account need an OAuth consent screen?

No. Consent screens are for flows where a human approves access. A service account authenticates as itself with a signed JWT — no browser, no consent screen, no refresh tokens to babysit. That is exactly why it suits servers and CLIs.

Why does GA4 still return 403 after granting Viewer access?

Usually one of three things: the grant went to the account level instead of the property, the property id in the request is a UA-style or measurement id rather than the numeric GA4 property id, or the Analytics Data API itself is not enabled in the project. The error body distinguishes them — read it.

Can one service account serve several websites?

Yes. Grants are per GA4 property and per Search Console property, so the same robot email can be added to each site it should read. One key, many grants, and revoking a single site is one row in that product's access screen.

Written by

Karol

Senior engineer and systems architect behind Tall Karol. Everything published here is grounded in real client work — no roundups, no tools that haven't run in production.

Why Tall KarolWork with Tall Karol

Related notes

More on systems integration

Related service: Systems Integration

Want this kind of engineering on your project?

Tall Karol takes on fractional and project-based engagements for startups and agencies.

Book a working session