Part of the AI coding agent security checklist
Check what the tokens your AI agent holds can do
The permission system on your laptop decides what the agent may run. The token decides what that command can do once it reaches GitHub, Stripe or AWS.
Checked against the GitHub, Google, Slack, Stripe, AWS, Railway and MCP documentation. Published .
Why scope is the part you control
An agent running in your shell can use every credential your account can reach: a token in GITHUB_TOKEN, the profiles in ~/.aws/credentials, the key an MCP server was configured with. Permission prompts decide which commands run. They do not narrow what a token can do once a command uses it.
The Model Context Protocol's security guidance lists what broad scopes cost when a token leaks. The blast radius expands, because one stolen token reaches unrelated tools and data. Privilege chaining becomes possible, because an attacker can call high-risk tools with no further prompt. The audit trail gets noisier, because one omnibus scope hides what each operation was for. Among its common mistakes is using wildcard or omnibus scopes such as *, all and full-access. Its answer is a progressive, least-privilege model: start with a small set and add scope when a privileged operation first needs it. It is written for MCP servers and clients, and the reasoning holds for any token an agent carries.
So for each credential there are two questions: what can it do, and which of that has it ever done? The sections below answer the first for GitHub, Google, Slack and Stripe, and the second wherever the provider keeps a record, with each provider's own tools.
Keep the token off the command line
Anything in a command's arguments is visible to every user on the machine through ps, and an interactive shell writes it to history. Each command below reads the token from a shell variable and hands it to curl on standard input through printf, a shell builtin, so it never appears in an argument list. Set each variable with read -rs, as in read -rs GITHUB_TOKEN: it does not echo, and the value stays out of history. Keep it out of the agent's reach too: a secret an agent reads ends up in its transcript.
GitHub
Classic personal access tokens and OAuth app tokens carry scopes, and GitHub returns them in the X-OAuth-Scopes header of any authenticated request. A HEAD request for a public profile is enough.
printf 'Authorization: Bearer %s\n' "$GITHUB_TOKEN" \ | curl -sI -H @- https://api.github.com/users/codertocat \ | grep -i x-oauth-scopes
In GitHub's own example the answer is repo, user. Four classic scopes deserve a second look when an agent holds them:
| Scope | What GitHub says it grants |
|---|---|
repo | Read and write access to code, commit statuses, repository invitations, collaborators, deployment statuses and repository webhooks, in public and private repositories. It also lets the token manage organisation-owned projects, invitations, team memberships and webhooks, and projects owned by users. |
workflow | Adding and updating GitHub Actions workflow files. |
delete_repo | Deleting any repository the account can administer. |
admin:org | Full management of the organisation, its teams, projects and memberships. |
A fine-grained token carries permissions rather than scopes, so that header does not describe it. For fine-grained tokens GitHub sends X-Accepted-GitHub-Permissions, which says what an endpoint requires, not what the token holds. GitHub does have an endpoint that lists fine-grained tokens with their permissions, GET /orgs/{org}/personal-access-tokens, but it covers tokens with access to an organisation and only GitHub Apps can call it. For your own token, open Settings > Developer settings > Personal access tokens > Fine-grained tokens.
For a token used on an organisation's resources, the record of what it did is the organisation audit log. Organisations on GitHub Enterprise Cloud can read it through the REST and GraphQL APIs; the REST endpoint needs an organisation owner, and a classic token needs read:audit_log.
To cut: GitHub recommends fine-grained tokens over classic ones whenever possible. Give one a single resource owner, choose Only select repositories, grant the minimal permissions, and set an expiration. Organisation owners can require approval before a fine-grained token reaches the organisation. Fine-grained tokens cannot yet reach Packages, Projects owned by a user account, the Checks API or more than one organisation. Where an agent needs those, keep the classic token's scopes narrow and give it an expiration as well.
Google's tokeninfo endpoint describes an access token, and Google documents it for user access tokens and for service account access tokens.
printf %s "$GOOGLE_TOKEN" \ | curl -s -G --data-urlencode access_token@- https://oauth2.googleapis.com/tokeninfo
The JSON that comes back includes scope, the granted scopes, with azp, aud, exp, expires_in and email. Google's documented form is curl "https://oauth2.googleapis.com/tokeninfo?access_token=ACCESS_TOKEN". It gives the same answer, with the token in curl's arguments; the version above sends the same query without that.
What a token has used is in the OAuth Token audit activity report of the Admin SDK Reports API, admin/reports/v1/activity/users/all/applications/token. Its activity events record an app calling an API, with app_name, client_id, api_name and method_name. Reading it needs admin privileges in the Workspace domain.
To cut: Google's Drive guidance is to choose the most narrowly focused scope and avoid any the app does not need. That means drive.file, which covers files the user opens with the app or shares with it, rather than drive, which covers every file. Google also recommends requesting a scope in context, when a feature first needs it, rather than all of them at sign-in.
Slack
Slack returns an x-oauth-scopes header with every Web API response, listing the scopes the calling token has. auth.test needs no scope of its own, so it works with any token.
printf 'Authorization: Bearer %s\n' "$SLACK_TOKEN" \ | curl -s -D - -o /dev/null -X POST -H @- https://slack.com/api/auth.test \ | grep -i x-oauth-scopes
Drop -o /dev/null and the grep, and the body says whose token it is: team, user, and bot_id for a bot token.
To cut: Slack documents that scopes requested when the app is installed again are added to the scopes the token already has, and that there is no way to remove scopes from an existing token without revoking it entirely. So revoke the token first, with auth.revoke or by uninstalling the app, then install the app again requesting only the scopes it needs. Slack's advice is to settle the minimum list of scopes the app needs while you build it.
Stripe
A Stripe key says what it is in its prefix, and the prefix is most of the answer.
| Key | Prefix | What it can do |
|---|---|---|
| Secret key | sk_live_sk_test_ |
Everything, on every Stripe API. Stripe does not recommend secret keys for new uses, and recommends moving existing ones to restricted keys. |
| Restricted key | rk_live_rk_test_ |
Only what you grant, resource by resource: None, Read or Write. Stripe recommends restricted keys for agents. |
| Agent key | rk_tagged Agent |
A restricted key marked at creation as belonging to an autonomous agent. Approval rules make a designated reviewer approve sensitive actions; the default rules cover payouts, refunds and account configuration changes. |
What a key has used is in its request logs: on the API keys page in the Dashboard, open the key's overflow menu and choose View request logs. A GET is a read; POST and DELETE are writes. Stripe's advice is to compare the successful calls with the key's permissions and remove the ones it did not use, through Edit key.
Agents that reach Stripe through its MCP server have a date to meet. From 31 October 2026, Stripe MCP no longer accepts full-access secret keys or restricted keys without the Agent tag, and answers them with a 401. For Claude Code, Stripe documents two set-ups. The first is OAuth: add the server, then authenticate through /mcp.
claude mcp add --transport http stripe https://mcp.stripe.com/
The second is an agent key in the project's .mcp.json, referenced from an environment variable rather than written into the file:
{
"mcpServers": {
"stripe": {
"type": "http",
"url": "https://mcp.stripe.com",
"headers": {
"Authorization": "Bearer ${STRIPE_AGENT_KEY}"
}
}
}
}Claude Code expands ${VAR} in an HTTP server's headers, and its documentation suggests checking .mcp.json into version control. That is why the key stays in the variable.
AWS
IAM's last accessed information exists to find permissions nobody uses. Generate a report for the agent's role, wait for it, then list the services it never touched.
JOB=$(aws iam generate-service-last-accessed-details \
--arn arn:aws:iam::123456789012:role/agent-role \
--query JobId --output text)
aws iam get-service-last-accessed-details --job-id "$JOB" \
--query JobStatus --output text # repeat until COMPLETED
aws iam get-service-last-accessed-details --job-id "$JOB" --output json \
| jq -r '.ServicesLastAccessed[] | select(.LastAuthenticated == null) | .ServiceNamespace'The last command prints each service the role's policies allow that no identity attempted in the tracking period: AWS leaves LastAuthenticated empty for those. Add --granularity ACTION_LEVEL to the first command for action data where AWS tracks it. The caller needs iam:GenerateServiceLastAccessedDetails and iam:GetServiceLastAccessedDetails.
To cut: rewrite the role's policy down to the services, and where action data exists the actions, that it used. AWS's unused access analyzers can watch for the same thing continuously.
Railway
Railway has four kinds of token, and only one is held to a single environment.
| Token | Reaches |
|---|---|
| Account | All your resources and workspaces. |
| Workspace | One workspace. |
| Project | One environment in one project, sent as a Project-Access-Token header. |
| OAuth | What the user granted. |
The Railway CLI reads a project token from RAILWAY_TOKEN and an account token from RAILWAY_API_TOKEN. On 24 April 2026, a Cursor agent working in PocketOS's staging environment found a Railway token in an unrelated file and called volumeDelete with it, removing the production database. By Railway's account, the legacy API path it called cascaded the delete, which made the volume's backups look unavailable. The token had been made to manage custom domains through the CLI, but it was an account token, so it could do anything the account could.
To cut: a project token for the one environment the agent works in, and no account token in a file the agent can read. For agents, Railway suggests its remote MCP server, which signs in through the browser, keeps no token on disk, uses short-lived tokens and lets you pick the workspaces and projects it reaches. Since 1 May 2026, a volume deleted through the API can be restored for 48 hours. That is a recovery window, not a permission.
If an agent has already read a file holding one, uvx ranwhat clean finds it in a Claude Code transcript as an assignment such as RAILWAY_TOKEN=..., or after a Project-Access-Token header. It has no pattern for the bare value.
All of them at once, read-only
ranwhat runs the same reads and scores what it finds. Every call is a read, sent only to the provider that issued the token.
export RANWHAT_GITHUB_TOKEN="$GITHUB_TOKEN" # likewise _GOOGLE_, _SLACK_, _STRIPE_ ranwhat live # ask each provider, read-only ranwhat live --pull-usage --github-org ORG # and which grants were used ranwhat scan profile.json --pull-usage # a declared profile, AWS included ranwhat demo # the report on a bundled example
Without installing anything, uvx ranwhat live works the same; Install has the other ways. AWS has no token to introspect, so it goes in a profile: JSON naming each credential and the scopes it was granted. With --pull-usage, ranwhat runs the two IAM commands above for the identity your AWS CLI resolves to (--aws-profile picks the profile).
{
"agent": "deploy-agent",
"credentials": [
{"provider": "aws", "label": "agent-role",
"scopes": ["s3:*", "iam:*", "lambda:InvokeFunction"]}
]
}In CI
An agent in a GitHub Actions job holds the job's GITHUB_TOKEN and whatever secrets the workflow passes it.
Before 1.0.74, anthropics/claude-code-action checked out a pull request's head branch, read .mcp.json from it and switched on every project MCP server it defined. Anyone who could open a pull request could add one. Once a privileged user or an automatic trigger ran the action on it, the attacker's server ran on the runner, with the workflow's secrets in reach (CVE-2026-47751). Workflows on @v1, @beta or @main have the fix; a pinned version needs 1.0.74 or later. Novee's Hugging Face demonstration also ran on an Actions runner, started from an issue comment.
That fix closes one path; scope decides what the next one reaches. GitHub's advice is to grant GITHUB_TOKEN the least access it needs, through a permissions block on the workflow or the job. Pass the agent step only the secrets it uses, and make any key it holds fine-grained or restricted.
Cut them down
One line per provider. Each change narrows what a leaked token can do, whoever ends up holding it.
Scope is one item on the AI coding agent security checklist, beside what the agent already ran and what it left on disk.
Sources
Every statement about a provider on this page comes from its official documentation, and every incident from the advisory or report linked here. Every statement about ranwhat comes from its source.
- MCP security best practicesscope minimisation, risks and common mistakes
- GitHub: scopes for OAuth appsX-OAuth-Scopes, what each scope grants
- GitHub: permissions for fine-grained tokensX-Accepted-GitHub-Permissions
- GitHub: managing personal access tokensfine-grained over classic, limits, expiration
- GitHub: organisation personal access tokens APIGitHub Apps only
- GitHub: reviewing the organisation audit logAPI access on Enterprise Cloud
- GitHub: get the audit log for an organisationowner, read:audit_log
- Google Cloud: token typestokeninfo for user and service account tokens
- Google Workspace: OAuth Token audit activityAdmin SDK Reports events
- Google Workspace: Reports API Python quickstartan administrator account in the domain
- Google Drive API: choose scopesdrive.file, drive, narrowest scope
- Google: OAuth 2.0 for web server appsincremental authorisation
- Slack: installing with OAuthx-oauth-scopes, additive scopes, auth.revoke
- Slack: auth.testno scope required, response fields
- Stripe: API keyskey types, agent keys, access policies
- Stripe: restricted API keyspermissions, request logs, migration
- Stripe: MCP31 October 2026 change, Claude Code set-up
- Claude Code: MCPvariable expansion in .mcp.json
- AWS IAM: last accessed informationtracking period, attempts, permissions
- AWS CLI: generate-service-last-accessed-details--arn, --granularity
- AWS CLI: get-service-last-accessed-detailsJobStatus, LastAuthenticated
- Railway: public APItoken types, Project-Access-Token
- Railway: CLIRAILWAY_TOKEN, RAILWAY_API_TOKEN
- The Register: the PocketOS deletion27 April 2026
- Railway: Your AI wants to nuke your databasethe remote MCP server
- Railway changelog: undoable deletes48-hour soft delete
- GHSA-8q5r-mmjf-575qCVE-2026-47751, fixed in 1.0.74
- GitHub: automatic token authenticationleast access for GITHUB_TOKEN
- Novee: the Hugging Face channelrun on an Actions runner
- Gen Digital: infostealers and your AI agent8 September 2026
- curl manual-H @-, --data-urlencode, -G
- ranwhat sourceintrospect.py, usage.py, catalog.py, clean.py