Site Kit Said I Was Missing Scopes. I Had Every Scope.
The error told me I had not granted permissions that I had granted twice.
If Site Kit returns missing_required_scopes to a request authenticated with an application password, while the same WordPress user sees the same data fine in wp-admin, your scopes are not the problem. WordPress accepts application passwords late in the request, after the init hook. Site Kit reads the current user on init. So it looks for a Google token belonging to user 0, finds none, and reports that as missing permissions. Reconnecting will not fix it. A small endpoint of your own will.
KEY FACTS
Site Kit 1.181.0 returns HTTP 403 missing_required_scopes to every application password request, including from the administrator whose Google account is connected.
The identical query works in a browser, signed in as that same user.
WordPress only honours an application password once REST_REQUEST is defined. That happens on parse_request, which runs after init.
WordPress core says so in a comment in its own source: “The ‘REST_REQUEST’ check here may happen too early for the constant to be available.”
Any code that reads the current user during init sees user 0 for that request.
The fix is one mu-plugin route that builds Site Kit’s user storage with get_current_user_id() inside the callback, where the number is finally right.
What the error actually claims
Here is the request, made this morning with an application password belonging to the site’s administrator. That administrator is the person whose Google account Site Kit is connected to.
If you do not have an application password yet, or the panel that generates them is missing from your profile entirely, start with the three checks that explain why WordPress hides it and come back here.
curl -u 'marcos@example.com:APP PASSWORD' \
'https://example.com/wp-json/google-site-kit/v1/modules/search-console/data/searchanalytics?slug=search-console&startDate=2026-07-01&endDate=2026-08-31&dimensions=query&limit=5'
And the answer:
{"code":"missing_required_scopes",
"message":"Site Kit can’t access the relevant data from Search Console because you haven’t granted all permissions requested during setup.",
"data":{"status":403,"scopes":["https://www.googleapis.com/auth/webmasters"]}}
Read that message as a user and it is unambiguous. You skipped a checkbox on the Google consent screen. Go back and tick it.
I had not skipped a checkbox. The webmasters scope it names was granted. I could load the Search Console panel in wp-admin and watch the numbers render.
Everything I tried that changed nothing
In order, over about two hours:
- Revoked the application password and made a new one.
- Reset Site Kit and set it up again from scratch.
- Disconnected the Google account, revoked the app in my Google account settings, reconnected, and read every line of the consent screen before accepting.
- Confirmed the Search Console property matched the site URL exactly, https and trailing slash included.
- Checked that the connection lived on the user I was authenticating as, not a second admin.
After each one the browser worked and the API returned the same 403, byte for byte.
That is the useful signal. When five different repairs produce an identical failure, you are not repairing the broken thing. Something upstream of all five is deciding the outcome before your change gets a vote.
The clue was in who was asking
Two requests. Same site, same route, same WordPress user, same Google token sitting in the database. One works, one does not.
The only difference is how the request proves who it is. The browser sends a login cookie. My script sends an application password. Those are not two spellings of the same thing. They are two different moments in the request at which WordPress decides you are user 1.
So I stopped reading OAuth documentation and started reading load order.
Why an application password arrives late
WordPress resolves the current user through the determine_current_user filter. Application passwords are handled there by wp_authenticate_application_password(), and that function starts by asking whether this is an API request at all:
// The 'REST_REQUEST' check here may happen too early for the constant to be available.
$is_api_request = ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) );
$is_api_request = apply_filters( 'application_password_is_api_request', $is_api_request );
That comment is in WordPress core. The people who wrote it knew.
REST_REQUEST is defined inside rest_api_loaded(), which runs on parse_request. That is inside WP::main(), well after init has finished and every plugin has loaded. The order is fixed:
| Moment | Cookie request | App password request |
|---|---|---|
| Plugins load | user 1 | user 0 |
init |
user 1 | user 0 |
parse_request, REST_REQUEST defined |
user 1 | user 1 |
| Route callback runs | user 1 | user 1 |
By the time your route callback runs, everything is correct. The damage was done earlier, by code that asked the question too soon and kept the answer.
This is the same shape as a bug I hit on my server, where a container could not read files that were plainly readable. The request was fine both times. The identity it was evaluated under was not. I wrote that one up in why an unprivileged container cannot read your files, and the debugging felt identical.
Why that breaks Site Kit in particular
Site Kit stores Google tokens per user, in user meta, keyed by the WordPress user ID. That is a reasonable design. Two admins can each connect their own Google account.
The object that reads those per-user values takes a user ID when it is constructed, and it is constructed while the plugin loads. On a cookie request that ID is 1. On an application password request it is 0, because the password has not been checked yet.
User 0 has no Google token. No token means no granted scopes. The check for “does this user have the webmasters scope” gets an empty list and fails, which raises missing_required_scopes.
The message is written for the common case, where a human clicked through consent and left a box unticked. It reads the same when the real answer is “there is no account here at all”. Those two states deserve different sentences, and getting one sentence for both is what cost me the afternoon.
The fix I tried first, which took the site down
Look at that core snippet again and the obvious move jumps out. There is a filter. application_password_is_api_request exists so you can say yes, this really is an API request, before the constant is available. Return true for anything hitting /wp-json/ with an Authorization header, and the password authenticates early, and Site Kit sees user 1 on init.
I wrote that as an mu-plugin. Every REST route on the site began returning 500. Not just Site Kit. All of them, including the ones I use to deploy files and would have used to undo this.
I deleted the file through the host’s File Manager and the site came back within seconds. Then I chose not to debug it. Authenticating a user earlier than core expects means auditing every plugin that touches the current user during load, and I wanted one number out of Search Console, not a research project.
Two things worth taking from that. First, mu-plugins load before everything and cannot be switched off from wp-admin, so before you put an experiment in that folder, know exactly how you will delete the file when wp-admin has stopped answering. Second, this is the point I made about backups you have never restored: a recovery plan you have not used is a guess. Mine happened to work. I had not earned that.
The bridge, which is boring and works
The better move is to stop fighting the load order. Let the request finish arriving. Then, at callback time, tell Site Kit who is asking.
One mu-plugin, one route:
add_action( 'rest_api_init', function () {
register_rest_route( 'mysite/v1', '/gsc', array(
'methods' => 'GET',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => 'mp_gsc_query',
) );
} );
function mp_gsc_query( WP_REST_Request $req ) {
if ( ! defined( 'GOOGLESITEKIT_PLUGIN_MAIN_FILE' ) ) {
return new WP_Error( 'no_site_kit', 'Site Kit is not active.', array( 'status' => 500 ) );
}
// This is the whole fix. By now the app password has been checked,
// so this returns the real user instead of 0.
$user_id = get_current_user_id();
$context = new \Google\Site_Kit\Core\Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE );
$user_options = new \Google\Site_Kit\Core\Storage\User_Options( $context, $user_id );
// From here you use Site Kit's own authentication and Search Console
// module, built on $user_options, and hand back what it returns.
}
The line that matters is the second argument to User_Options. Site Kit will happily work with the right user. Nobody had told it who that was.
The rest of the callback maps a few query parameters onto a Search Console query and returns the rows. Site Kit keeps refreshing the stored token on its own schedule, so the bridge never touches OAuth and never needs attention when the token rotates.
Same site, same application password, ten minutes later:
curl -u 'marcos@example.com:APP PASSWORD' \
'https://example.com/wp-json/mysite/v1/gsc?start=2026-07-01&end=2026-08-31&dimensions=query&limit=3'
{"property":"https://example.com/",
"rows":[
{"keys":["..."],"clicks":...,"impressions":...,"position":...},
{"keys":["..."],"clicks":...,"impressions":...,"position":...},
{"keys":["..."],"clicks":...,"impressions":...,"position":...}
]}
HTTP 200, and real rows. I have blanked my own query numbers here, but that is the shape of the answer. That took about forty lines of PHP after two hours of looking in the wrong place, which is the usual ratio.
What this is not
It is not a way around OAuth. The token still belongs to a real Google account that granted the scopes in a real browser. If nobody has connected Site Kit, the bridge returns nothing, because there is nothing to return. All it does is point Site Kit at the user who was already there.
It is also not free of risk. The route is guarded by manage_options, so it is exactly as safe as the application password you use to call it. Treat that password like the admin login it is. If you only ever read data, keep the route read only, which mine is.
FAQ
Does resetting Site Kit fix missing_required_scopes?
Not for API requests. Resetting rebuilds the stored token for a user who already had a working one. The API request is failing before it looks at any token, because it is looking under user 0.
How can I tell this is my problem and not a real missing scope?
Load the same data in wp-admin as the same WordPress user. If the browser shows the numbers and the API returns 403, your scopes are granted and the identity is the problem.
Is this a WordPress bug or a Site Kit bug?
Neither project treats it as one. Core knows the timing check is fragile and says so in a source comment. Site Kit reads the current user the way plugins normally do. It is an interaction between two reasonable decisions, which is the kind of bug that survives longest.
Will this hit other plugins too?
Any plugin that stores credentials per user and captures the user ID while loading will behave the same way for application password requests. If a plugin’s REST data works in a browser and fails from a script, check that before you check your credentials. The other common reason a script gets treated as a stranger is the server stripping the Authorization header before PHP sees it.
Can I use the application_password_is_api_request filter instead?
You can try. It is the documented seam and it may work fine on a simpler site. On mine it took every REST route down with a 500, so I would only attempt it with a way to delete the file that does not depend on wp-admin.
Do I need to call google-site-kit/v1 routes at all?
No, and I would not. Those routes are built for the plugin’s own dashboard. A small route of your own returns the shape you actually want and does not break when Site Kit reorganises its internals.
The version to act on
If Site Kit tells your script it is missing scopes, check the browser first. If the browser is fine, stop touching the Google connection. The request is authenticating too late for the plugin to notice, and no amount of reconnecting moves that. Write one route, build Site Kit’s user storage with the user ID you get inside the callback, and read your data from there.
This endpoint is now part of the setup that lets Claude run this site on a schedule, which I documented in full in how I let Claude run my website. Search Console was the last piece that would not automate. It automates now. That same bridge is how I later found out Google had never once tried to download a sitemap I kept resubmitting, which I wrote up in Search Console never read my sitemap.
Stay in the loop.
No spam, just signal.
I send one email when I publish something worth reading. Stock deep dives, new projects, tech breakdowns, and what I'm learning from building in public.
Free forever. Unsubscribe anytime. I respect your inbox.