AdMob API OAuth Setup in 2026: Scopes, Refresh Tokens, and Common 403 Errors
Google's docs don't tell you which AdMob scope you actually need. The two scopes compared, OAuth client setup, refresh token quirks, and how to fix 403 insufficient_scope.
Context above, deep read below. Use the TOC to move section by section without losing the thread.
Last sprint I wired up an AdMob earnings dashboard for our portfolio app and walked straight into the same OAuth wall every dev hits. Google's protocol docs cover the wire format, but they punt on the question you actually ask first: which scope do I request? The reference page lists endpoints, and the OAuth page lists scopes, and the two pages never meet. This piece is the connector I wish had existed when I started.
The docs don't tell you which scope you need
Open the AdMob API getting-started page and you get two scope URLs: admob.readonly and admob.report. Open the REST reference and you get a tree of endpoints with HTTP methods and field schemas. Nowhere does either page tell you that accounts.list works under admob.readonly but accounts.networkReport.generate is the right fit for admob.report. There is no per-endpoint scope table. You have to pick a scope, try it, and let the 403 errors teach you the mapping. That is the actual workflow Google ships in 2026, and it costs every new integrator a half day.
The two scopes side-by-side: admob.readonly vs admob.report
admob.readonly |
admob.report |
|
|---|---|---|
| Full scope URL | https://www.googleapis.com/auth/admob.readonly |
https://www.googleapis.com/auth/admob.report |
| Grants access to | "See all AdMob data. This may include account information, inventory and mediation settings, reports, and other data." | "See ad performance and earnings reports. See publisher ID, timezone, and default currency code." |
| Does NOT grant | "This doesn't include sensitive data, such as payments or campaign details." | Google docs do not state an explicit exclusion clause; treat as the narrower scope by elimination |
| Use this if you... | Need both inventory (apps, ad units, mediation groups) and reports in one credential | Only build a reporting pipeline and want the smallest consent surface |
| Source | Google docs | same |
In practice every dashboard I have shipped ends up requesting admob.readonly because the moment a PM asks "can we also list which apps are earning the most," you need the inventory endpoints, and admob.report alone will not give you those. The narrower scope is only worth it if your service really is read-earnings-only and you care about consent-screen optics for an external launch.
One more thing worth saying out loud: scopes are additive, not hierarchical. Requesting both in one consent flow is legal and common, and the access token you receive will carry both scopes in its scope claim. There is no penalty for asking for both, only the UX cost of one extra checkbox on the consent screen.
Setting up the OAuth client in GCP
Create a project in Google Cloud Console, enable the AdMob API from the API Library, and open the OAuth consent screen configuration. Pick External user type unless you are inside a Google Workspace org. Add the scope https://www.googleapis.com/auth/admob.readonly (or admob.report if that is all you need) to the consent screen scope list. This step is the one people skip; if the scope is not on the consent screen, the authorization request silently downgrades and your token comes back without it.
Then go to Credentials and create an OAuth 2.0 Client ID. Pick the right application type: Web application if you have a backend, Desktop app if you are running a CLI tool, or Installed app for a script that uses a loopback redirect. For a Web application client, fill in the authorized redirect URIs exactly as your server will call them. Google docs are blunt: "the http or https scheme, case, and trailing slash ('/') must all match." A redirect URI registered as https://app.example.com/oauth/callback will reject a request to https://app.example.com/oauth/callback/ and produce redirect_uri_mismatch.
Download the client JSON or copy the client ID and secret into your secret store. While you are here, decide whether the OAuth consent screen stays in Testing or moves to Production. Testing is fine for a single internal user, but Testing mode pins refresh tokens to a 7-day expiry, which will absolutely surprise you on day 8 in staging. If this credential is going to power a long-running server job, push the consent screen through verification to Production before you ship.
Getting and refreshing tokens
The initial authorization request must include access_type=offline if you want a refresh token at all. Without it Google issues only the one-hour access token and your job dies at minute 61. The Google docs are explicit: setting access_type=offline "instructs the Google authorization server to return a refresh token and an access token the first time that your application exchanges an authorization code for tokens."
After the user consents, your callback receives a code. Exchange it at the token endpoint:
POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded
code=4/P7q7W91a-oMsCeLvIaQm6bTrgtp7&
client_id=your_client_id&
client_secret=your_client_secret&
redirect_uri=https%3A//your.app/oauth/callback&
grant_type=authorization_code
The response carries access_token, expires_in (3600 seconds by default), token_type: Bearer, and refresh_token. Store the refresh token immediately. Google warns: "Note that the refresh token is only returned on the first authorization." If you re-run the consent flow with the same user and client, the next response will omit refresh_token entirely, and you will think your code broke. The [community-verified] workaround is to add prompt=consent to the authorization URL, which forces Google to re-issue a refresh token on every consent. Use sparingly.
To refresh:
POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded
client_id=your_client_id&
client_secret=your_client_secret&
refresh_token=1//0gC...stored_refresh_token&
grant_type=refresh_token
The response gives you a new access token. The refresh token itself stays valid until revocation.
Common errors and fixes
403 insufficient_scope: your access token does not carry the scope the endpoint requires. Most often the consent screen scope list is missing the scope you put in the authorization request, so consent silently issued a narrower token. Fix: add the scope to the consent screen, force a new consent flow with prompt=consent, and reissue the refresh token.
400 invalid_grant: your refresh token is no longer accepted. The three most likely causes in AdMob workflows: (1) the consent screen is still in Testing and 7 days have passed; (2) the refresh token has not been used for six months and Google expired it; (3) the user changed their Google password and the token contained Gmail scopes, which can cascade to invalidating other tokens depending on the auth session. Fix: rerun the consent flow, capture a fresh refresh token, and if this hurts in production, move the consent screen out of Testing.
403 PERMISSION_DENIED: the user authenticated successfully but does not have access to any AdMob publisher account. Fix: confirm that the Google account that ran consent is actually invited to the AdMob account at admob.google.com under Settings > Account access. New users sometimes accept the consent screen under their personal Gmail instead of the workspace account that owns AdMob.
Token works but accounts.list returns an empty array. No error, just {}. Root cause: the authenticated user is signed into a different AdMob publisher than the one you expected, or has zero publishers attached. Fix: log the user identity claim on the token, cross-check at admob.google.com which publisher that user owns, and rerun consent under the correct account.
Quick reference: which scope for which endpoint
Google publishes no per-endpoint scope mapping, so this table is what works for me in production. Treat the labels honestly.
| Endpoint | Recommended scope | Source |
|---|---|---|
accounts.get (v1) |
admob.readonly |
[community-verified] |
accounts.list (v1) |
admob.readonly |
[community-verified] |
accounts.apps.list (v1) |
admob.readonly |
[community-verified] |
accounts.adUnits.list (v1) |
admob.readonly |
[community-verified] |
accounts.mediationReport.generate (v1) |
admob.report |
[community-verified] |
accounts.networkReport.generate (v1) |
admob.report |
[community-verified] |
accounts.adSources.list (v1beta) |
admob.readonly |
[community-verified] |
accounts.adSources.adapters.list (v1beta) |
admob.readonly |
[community-verified] |
accounts.adUnits.create (v1beta) |
unknown, Google docs do not specify a write scope | [unverified] |
accounts.adUnits.adUnitMappings.create (v1beta) |
unknown write scope | [unverified] |
accounts.adUnitMappings.batchCreate (v1beta) |
unknown write scope | [unverified] |
accounts.apps.create (v1beta) |
unknown write scope | [unverified] |
accounts.campaignReport.generate (v1beta) |
admob.report |
[community-verified] |
accounts.mediationGroups.list (v1beta) |
admob.readonly |
[community-verified] |
accounts.mediationGroups.create / patch (v1beta) |
unknown write scope | [unverified] |
accounts.mediationGroups.mediationAbExperiments.create / stop (v1beta) |
unknown write scope | [unverified] |
If you have a documented source that maps any of these [unverified] rows, send corrections to xzf224@gmail.com and I will update the table.
Jump to a section
Pass this article along
Send it to your preferred platform or copy the link.
Before you move on
Next step
Finished reading? Continue comparing tools in the directory.
Browse tools