How to set up domain-wide delegation
Five steps. The first four are quick; the fifth is the one people skip, and skipping it is why delegation appears to work in testing and fails in production three weeks later.
Before you start
- You need a super administrator on the Workspace domain. A delegated admin with user-management rights is not enough.
- You need a Google Cloud project you control, with billing attached if you plan to exceed free quotas.
- Decide the scope list before you start. Changing it later means editing the grant in every tenant.
Step 1 — Create the service account
In the Cloud console, create a service account in your project and generate a JSON key. Store that key like a password: anyone holding it can act as any user you have authorized scopes for. Then enable each API you intend to call in the same project — a missing API enablement produces a different, less obvious error than a missing scope.
Step 2 — Copy the client ID, not the email
Open the service account and copy its OAuth 2 client ID — a long numeric string. The Admin console keys the grant to this number. Pasting the service account email address instead is a common false start.
Step 3 — Authorize the scopes
In the Workspace Admin console, go to Security → Access and data control → API controls → Manage domain-wide delegation, add the client ID, and paste your scopes as a comma-separated list.
https://www.googleapis.com/auth/gmail.readonly and https://mail.google.com/ are different grants. Authorizing one does not imply the other, and requesting an unauthorized one fails the whole request — not just that API.Step 4 — Impersonate in code
The pattern is the same in every language: load the key, attach scopes, then set the subject to the user you are acting as.
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
creds = service_account.Credentials.from_service_account_file(
"/secure/path/key.json", scopes=SCOPES
).with_subject("person@yourdomain.com") # the user you are acting as
gmail = build("gmail", "v1", credentials=creds)
print(gmail.users().getProfile(userId="me").execute())The with_subject call is the delegation. Without it you are the service account itself — which owns no mailbox, no calendar and no Drive storage.
Step 5 — Verify each API, not just one
Refresh a token and make one cheap read per API. This is the step that catches a scope you forgot to authorize, an API you forgot to enable, and a tenant where the grant never propagated. A green Gmail check tells you nothing about Drive.
for api, probe in [
("gmail", lambda c: build("gmail","v1",credentials=c).users().getProfile(userId="me").execute()),
("drive", lambda c: build("drive","v3",credentials=c).about().get(fields="storageQuota").execute()),
("calendar", lambda c: build("calendar","v3",credentials=c).calendarList().list().execute()),
]:
try:
probe(creds); print(api, "OK")
except Exception as e:
print(api, "FAILED:", repr(e)[:160])Two things worth doing on day one
- Write the scope list down somewhere durable, next to which tenants it is authorized in. When something breaks in six months, this document is the difference between ten minutes and an afternoon.
- Impersonate a real, permanent mailbox — not a personal account that may be deprovisioned. Drive files created under a subject belong to that user.
Stuck at step 3 or 5?
Those are the two that consume the day. We do this setup as a service, including multi-tenant grants and a verification pass that proves every API answers before we hand it over. $500 per hour.
Get it set up properly