Your service account's roles are not the roles it uses
Our headless front end authenticates to Drupal as a service account over OAuth client_credentials. During a review we noticed that account held the administrator role.
That is the kind of thing that stops a review. An anonymous-facing front end holding a token minted from an admin account is exactly the shape of a serious finding. So we went looking for the leak.
There wasn't one. And understanding why turned out to be more useful than the vulnerability would have been.
The token does not carry the account's roles
In simple_oauth with role granularity, the scope is the security boundary — not the user. Account roles are stripped from a client_credentials token. The permissions the token actually carries are the ones its granted scopes map to, and nothing else.
We proved it rather than trusting the documentation, and the cleanest proof used one account against two consumers:
- Same user, roles
[]. Scopepreviewer→ the unpublished draft comes back. - Same user, roles
[]. Scopenextjs_site→null.
Identical account. Different scope. Different answer. The user is not what decides.
Which means the administrator role on that account was never granting the front end anything. It was inert. The finding was real — the role should not have been there — but its severity was not what it looked like. It was a loaded gun with no firing pin: harmless today, and one configuration change away from not being harmless. We removed it.
The check that would have lied to us
Before removing the role in production, we wanted a control. The obvious plan: block the service account and confirm the front end breaks in the way we predict. We tried it on a development environment first.
Good thing. Here is what a blocked service account does:
- Minting a token still succeeds. You get a 200 and a real access token. Every health check you would think to write passes.
- Every use of that token 401s. Every content request from the front end fails.
In production that is the public website going dark, while the credential check you set up to catch exactly this reports green.
The uncomfortable detail: our own first draft of the verification plan said "confirm token minting still succeeds." That check passes on a blocked account. We wrote a test that would have certified the outage as healthy. The test was not wrong about what it measured. It measured the wrong thing — issuance, when the thing we cared about was use.
If you take one operational rule from this: never verify a credential by minting a token. Verify it by spending one. Authentication and authorization fail at different moments, and the gap between them is where the false confidence lives.
Ask the token what it thinks it is
The other trap on the way was reading the wrong signal entirely. We hit /jsonapi/user/user with the service token, got a 200, and briefly believed we had confirmed the leak.
We had not. JSON:API does not return 403 for collections you cannot fully read — it filters by access and returns what you are allowed to see. A 200 is the normal response for a request that found nothing privileged. The control settles it in one command: anonymous, with no token at all, also gets a 200 on that endpoint. A result that a request with no credentials reproduces is not evidence about your credentials.
The right instrument was /oauth/debug, which reports what the token itself resolves to:
roles: ["authenticated"]
Not administrator. The token never had it. That is the answer to the actual question, and it took one request to get once we asked the right endpoint.
What we changed
The role came off the account, via a post_update hook so the change travels with a deploy instead of living in someone's memory of a production admin session. Each consumer now holds one scope with one job: the public client can read published content, and the preview client can read unpublished drafts. A bug in a public page cannot leak a draft, because the client that renders public pages has no scope that can see one.
None of this was a vulnerability. It was worse in a quieter way: a system whose real access rules lived somewhere other than where everyone was looking. The account said administrator. The token said authenticated. Only one of them was in the request path, and it was not the one visible in the user interface.