Access tokens
Overview
An access token is a credential you hand to something that cannot open a browser — a CI job, a deploy script, a bot, a git client on a build machine. You choose exactly which operations it may perform and, if you want, the single group or repository it may touch, so a credential that leaks costs you the reach you gave it and nothing more.
There are two kinds, and the difference is whose identity the token carries. A personal access token begins gmp_ and acts as you, with everything you can reach. A service-account token begins gms_ and acts as a bot that belongs to a group or a repository, which is where a long-lived credential belongs when the automation should outlive whoever set it up.
The plaintext is shown exactly once, at the moment you mint it. Nothing — not a later listing, not an Owner, not us — can show it to you again.
Quick start
Create an access token
Open your avatar menu, choose Settings, then Access tokens, then New token.
Name it after what will hold it, tick only the scopes it needs, and set an expiry.
Choose Create token and copy it straight away — it is shown once. Scripts and the CLI read it from the environment:
export GITMOAP_TOKEN="paste-the-token-here"
Before you start
- Be signed in as a person.Minting a token is one of the few things a service account cannot do for itself, whatever scopes its own token carries. A provisioning script running as a bot is refused with
400 BAD_REQUEST, so the first credential in any chain is always minted by a human or by a token that human already holds. - A way to authenticate the mint call itself.In the web app that is your ordinary session. From the CLI, run
gitmoap loginonce and the stored login covers it. From the API, a Cognito session token or an existing access token carryingtoken:manageboth work. - A token with the right scope, if you are scripting this rather than clicking:
token:readto list tokens,token:manageto mint and revoke them. Scopes are fixed at mint time and cannot be added afterwards. - Maintainer or Owner on the owning group or repository, to mint, list or revoke a service account's tokens. That bar is checked against the group or repository the account itself belongs to — being an Owner elsewhere in the tree does not help — and the service account has to exist already. Service accounts covers creating one.
- Read access to anything you bind the token to.A binding is refused unless you can already view that group or repository — Reporter on a private one, and any signed-in account on a public or internal one.
- Somewhere to put the secret before you close the response.A password manager, your CI provider's secret store, or a file with restricted permissions. Copying it into a shell history or a chat message is how tokens leak.
- A list of what the token actually has to do.Scopes are chosen at mint time, and widening one means minting a replacement, so it is worth spending a minute on the list rather than reaching for
write_api.
How it works
Three gates, and a token can only ever narrow
Every request made with a token passes three independent checks. A token restricts the first two. The third is your standing, or the service account's, and a token cannot raise it.
request carrying a token ├─ does the token carry the scope this operation needs? no → 403 INSUFFICIENT_SCOPE ├─ is the target inside the token's binding? no → 403 FORBIDDEN └─ does the principal's access level allow the action? no → 403 FORBIDDEN, or 404 on a private resourceThe consequence people are most often surprised by is that a token is never a promotion. A token minted with git:write by someone who is a Reporter on a repository still cannot push to it: the scope says this credential is allowed to attempt pushes, and the access level says this person is allowed to push, and both have to agree. Raise the access level to change what the token can do; re-minting it with more scopes changes nothing on its own.
A browser session works the other way round — a person signed in acts with a full set of scopes and is bounded only by their access levels. Scope restriction is what an access token is for.
Scopes
A scope names one operation family, spelled domain:verb — branch:create, repo:read, merge_request:merge. The split is deliberately fine, so a token that opens merge requests does not thereby gain the ability to merge them. Every operation on every page of this documentation names the one scope it needs, in its reference card.
Common jobs and the scopes they need:
| What the token has to do | Scopes to give it |
|---|---|
| Clone and fetch over git | git:read |
| Push over git | git:read, git:write |
| Read repositories, branches, commits and file contents | repo:read, branch:read, commit:read, tree:read |
| Commit file changes, or resolve a merge request's conflicts, through the API | commit:create |
| Open and update merge requests | merge_request:create, merge_request:read, merge_request:update |
| Comment on a merge request | mr_comment:create, mr_comment:read |
| Report a build result against a commit | commit_status:write |
| Start a pipeline and watch it | pipeline:trigger, pipeline:read |
| Read and file issues | issue:read, issue:create |
| Cut a release and upload its assets | release:create, release:read |
| Publish and install packages | package:read, package:write |
| Manage its own credentials | token:read, token:manage |
Bundles are a shorthand, not a subscription
Instead of listing scopes you may name a bundle, and it is expanded into individual scopes at the moment you mint the token. What is stored is the expansion. A scope added to the platform after your token was minted is not added to your token, so a long-lived write_api token does not silently accumulate reach as the API grows. That is deliberate, and it is also why a token that worked against everything last year may return 403 INSUFFICIENT_SCOPE on something new.
| Bundle | What it expands to |
|---|---|
read_api | every scope in the catalogue whose verb is exactly read |
write_api | every scope in the catalogue except the five below that no bundle carries |
read_repository | every :read scope in the repository-shaped domains: repositories, branches, tags, releases, commits, diffs, file trees, merge requests and their comments, reviewers, branch rules, push rules, merge-request settings, commit statuses, pipelines, and work items with their settings, custom types and comments |
write_repository | every scope of those same domains, reads included, plus the three that have no :read scope to give: work-item reactions, labels and milestones |
mcp_agent | the reads an AI agent needs through the MCP server: your profile, groups, repositories and their files, commits, commit statuses, diffs, branches, tags and releases, work items and their comments, merge requests with their comments and reviewers, wikis, search, your dashboard and insights — and nothing that reads credentials, access, billing or the audit log. Its one write is feedback:create, a report to Gitmoap about the tools, which changes nothing of yours — see Feedback about Gitmoap |
mcp_agent_write | everything in mcp_agent, plus what an agent needs to work alongside you: opening, changing and commenting on work items and merge requests, reacting to comments, requesting reviewers, and creating branches. Not merging, approving, deleting a branch, releasing, starting a pipeline, or changing who can reach what — name those scopes yourself if an agent should have them |
read_api goes by the verb, not by the sense: a scope that only reads but is spelled with another verb — audit:verify is the one people meet — is not in it and has to be named.
No bundle can approve anything, or reshape who can reach what. Five scopes are left out of every bundle, write_api included, and a token carries one only when you name it: reviewer:review (approving, rejecting or withdrawing a merge-request review), pipeline:approve (granting or denying a pipeline step that waits for approval), ai_org:approve (publishing an AI organization's revisions and resuming what was paused), access:administer (limiting access, and stopping a group or repository inheriting it) and role:manage (an organization's custom roles). An approval is only worth something because a second person chose to give it, and the last two change what everyone beneath can do, so a token minted for automation should not be able to do either by accident.
Three things sit outside both repository bundles, and each has caught somebody out. Git is a domain of its own, so a token that has to clone, fetch or push needs git:read and git:write named: read_api reaches git:read, and write_api is the only bundle that reaches git:write. Reactions on a merge-request comment are mr_reaction:manage, which neither repository bundle includes, even though the matching work-item scope is in write_repository. And the domains that reach past one repository's contents — webhooks, pipeline variables, the service catalog, search, package registries, group and member administration, and token management itself — are in neither, deliberately: a bundle for what is in a repository should not hand out the ability to change who can reach it.
The binding narrows a token to one subtree
A binding restricts a token to a single group or a single repository, and it is fixed at mint time like everything else about a token.
acme ← bound here: every group and repository below, forever└── platform ├── api-gateway ← bound here: this repository and nothing else └── billingA group binding follows the tree, so it covers repositories created inside that group after the token was minted — which is what makes a group-bound token workable for a team, and what makes it a bigger grant than it looks. A repository binding covers exactly one repository.
Three details worth knowing before you rely on it:
- You can only bind to something you can see. Naming a group or repository you have no access to is refused, so a token cannot be pointed at a subtree its creator could not reach.
- The binding beats public visibility. A repository-bound token is refused on every other repository, including public ones that any anonymous caller could read. The binding is checked first, on purpose.
- A binding outlives its target. Delete the bound repository and the token keeps existing, but it now names something that resolves to nothing, so every call that touches a group or repository answers
403 FORBIDDEN. Revoke it and mint a fresh one rather than trying to work out which calls still function.
Calls that name no group and no repository at all — your own profile, your own token list — are governed by scope alone, because there is nothing for a binding to compare against. That makes token:manage the scope to hand out most carefully: a token carrying it can mint a further personal token with any scopes in the catalogue and no binding at all, however narrow the token that minted it was. Treat token:manage as the highest privilege in the catalogue and leave it off automation tokens that do not manage credentials.
Expiry, revocation and last use
Expiry is optional and measured in whole days from the moment of minting. Omit it and the token never expires, which is occasionally the right answer and usually is not — a credential nobody has to think about is a credential nobody notices leaking.
Every request validates the token afresh, which gives both halves their useful property: an expiry takes effect the instant it lapses without anything having to run, and a revocation takes effect on the very next request rather than when some cache decides. There is no grace period in either direction.
The last-used timestamp on each token answers is anything still relying on this? A token's very first request stamps it straight away; after that it is refreshed at most once an hour per token, so a token being hammered every second can still show a timestamp up to an hour old. Read it as a signal of life, not as an audit trail. The record of what a token did is the audit log, which holds every change it made, every clone and fetch it performed, and every read of a token list — as well as the mint and the revocation themselves. Personal-token events belong to the person rather than to an organisation, so an Owner reading a group's log will not find them there; Audit log explains which view holds what.
How to
Mint a personal access token
Name it after the thing that will hold it — laptop git, release script, terraform plan in CI — because in six months the name is the only clue you will have about what breaks when you revoke it. Give it the smallest set of scopes that does the job and an expiry you are willing to diarise.
Open your avatar menu, choose Settings, then Access tokens, then New token. Give it a name, tick the scopes it needs, and set an expiry. Optionally choose a group or a repository under Limit to a resource to bind it. Create token shows the token once, with a copy button — copy it before you leave the page, because reopening this screen shows only the name, the scopes and the dates.
gitmoap access-tokens create-personal-token --name <name> --scopes '<json>'curl -X POST https://api.gitmoap.example/user/tokens \ -H "Authorization: Bearer $GITMOAP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "<string>" }'The response carries the plaintext under token and the token's metadata under access_token. Store the plaintext immediately; the metadata is what you will see in every later listing.
Narrow a token to a single repository or group
Add a resource to the same call, naming either a repository or a group. Bind to a repository when one job touches one codebase, and to a group when the job has to reach several — remembering that a group binding follows the tree and picks up repositories created later.
gitmoap access-tokens create-personal-token \ --name "api-gateway CI" \ --scopes '["git:read","commit_status:write"]' \ --expires-in-days 90 \ --resource '{"scope_type":"repository","scope_id":"3f1c0a52-7d84-4c2e-9a11-6b0e5f2d8c41"}'Bind by default for anything that lives in CI. A token that can only reach the repository whose pipeline holds it is the difference between a bad afternoon and a bad quarter.
Authenticate with a token
The same string works everywhere. Send it whole — the value carries its own identifier as well as its secret, so a truncated or reformatted token is simply invalid.
Against the API, as an ordinary bearer credential:
curl -H "Authorization: Bearer gmp_..." https://api.gitmoap.com/user/tokensFrom the CLI, either pass it per command or put it in the environment, which takes precedence over any stored login:
export GITMOAP_TOKEN="gmp_..."gitmoap repos list --group-path acme/platform --output jsonOver git, the remote helper reads the same variable, and a token set this way is used exactly as given and never refreshed:
export GITMOAP_TOKEN="gms_..."git clone gitmoap://acme/platform/api-gatewayGit needs git:read to clone or fetch and git:write as well to push, and honours the binding — a repository-bound token clones that repository and refuses every other.
Review the tokens you hold
Tokens come back newest first, 20 to a page, up to 100 if you ask. Everything but the secret is there: the name, the scopes as they were expanded at mint time, the binding, and when it was created, expires and was last used. The id you need in order to revoke one is the access_token_id field, not the token you copied at the mint.
Open your avatar menu, choose Settings, then Access tokens. Each row shows the name, its scopes, what it is limited to, and when it was created, expires and was last used. A token past its expiry stops working the moment it lapses, but its row stays in the list until you revoke it.
gitmoap access-tokens list-personal-tokenscurl -X GET https://api.gitmoap.example/user/tokens \ -H "Authorization: Bearer $GITMOAP_TOKEN"A token an AI app received when you connected it by signing in is in this list too, labelled with the app's name under oauth. It expires an hour after its last renewal and the app renews it on its own, so its expiry moves; revoking it disconnects the app — see Connect an AI app by signing in.
Go through this list on a schedule. Two things are worth acting on every time: a token with no expiry, and a token whose last use is months old or empty. The first is a standing risk, the second is a credential nobody would notice you removing.
Revoke a personal token
Warning
Revoking a token is immediate and cannot be undone. The very next request carrying it fails with 401 UNAUTHORIZED, including requests from jobs already running, and there is no way to restore it — the secret was never stored, so nothing exists to bring back. Anything still holding that token needs a new one minted and deployed.
Revoke as soon as you suspect exposure: a token pasted into a chat, committed to a repository, or held by a laptop that left the building. A leaked token is at least as urgent as a leaked password, because nothing prompts it for a second factor.
Open your avatar menu, choose Settings, then Access tokens. Find the token and choose Revoke at the end of its row. The confirmation names the token and when it was last used; Revoke takes effect immediately.
gitmoap access-tokens revoke-personal-token --token-id <id>curl -X DELETE https://api.gitmoap.example/user/tokens/{token_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Mint a token for a service account
This is how a bot gets its credentials. The token acts as the service account, not as you, so it carries the account's access level and keeps working after you leave the team. You need token:manage on your own credential and Maintainer or Owner on the group or repository the account belongs to.
Open the group or repository, choose Settings, then Service accounts, then the account. Under Tokens, choose New token, name it, pick its scopes and expiry, optionally bind it, and Create. The plaintext appears once — put it straight into your CI secret store.
gitmoap access-tokens create-sa-token \ --sa-id <id> \ --name <name> \ --scopes '<json>'curl -X POST https://api.gitmoap.example/service-accounts/{sa_id}/tokens \ -H "Authorization: Bearer $GITMOAP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "<string>" }'Two things this token cannot do, however you configure it. It cannot exceed the account's access level, so git:write on a Reporter-level account still cannot push. And it does not inherit anything from you: the account's own standing is what governs it after you have gone.
Review a service account's tokens
Before you change or retire a bot, look at what it is holding. The same fields come back as for your own tokens, newest first.
Open the group or repository, choose Settings, then Service accounts, then the account. The Tokens panel lists every credential minted for it, with its scopes, its binding, and when it was last used. Reading this list is recorded in the audit log.
gitmoap access-tokens list-sa-tokens --sa-id <id>curl -X GET https://api.gitmoap.example/service-accounts/{sa_id}/tokens \ -H "Authorization: Bearer $GITMOAP_TOKEN"A bot with five tokens and one recent use is usually four rotations that were never cleaned up. Revoke the stale ones — an unused token is a leak waiting for an audience.
Revoke a service account's token
Warning
Revoking a service account's token is immediate and cannot be undone. Any job still holding it starts failing on its next request with 401 UNAUTHORIZED, and a job already running fails at its next call rather than finishing. Confirm what is using the token — the last-used timestamp is the fastest check — before you revoke it.
Revoking leaves the account itself, its history and its access level untouched, which makes it the right way to pause a bot you intend to bring back. Deleting the account instead revokes every token it holds at once and cannot be undone either; that route is covered in Service accounts.
Open the account's Tokens panel, find the token, and choose Revoke. The confirmation names the token and when it was last used.
gitmoap access-tokens revoke-sa-token --sa-id <id> --token-id <id>curl -X DELETE https://api.gitmoap.example/service-accounts/{sa_id}/tokens/{token_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Rotate a token without downtime
Because nothing about a token can be edited, every change is a rotation, and a rotation done in this order never has a gap:
Mint the replacement with the scopes, binding and expiry you want. Two working tokens at once is fine and is the whole point.
Deploy the new value everywhere the old one lives — CI secrets, deploy hosts, the environment of anything long-running.
Restart or re-run whatever caches the old value in memory, then confirm the new token's last-used timestamp is set. The first request a token makes stamps it immediately, so a timestamp that is still blank after the new value has run once means the new value is not the one being sent. Only later refreshes are throttled to once an hour.
Revoke the old token, and confirm nothing breaks.
Set a calendar reminder a week before an expiry rather than discovering it from a failing deploy at the worst possible moment. If a token has already expired, the same four steps work — you have just done step 4 involuntarily.
Reference
6 endpoints
POST/user/tokenstoken:manage
Create a personal access token for the authenticated user
Token scope
token:manage
Counts against
tokens_per_principal
Send
Body
- expires_in_daysint32optionalDays until the token expires. Omit for a non-expiring token.
- namestring
- resourceobjectoptional
Refuses
400Invalid request401Unauthorized403Forbidden — the caller's access level or token scope is insufficient409The request conflicts with the resource's current state; `reason` says how429Token limit reached for this user
Receive
access_tokenobject10 fields
- access_token_iduuid
- created_atstring
- expires_atobjectoptional
- last_used_atobjectoptional
- namestring
- oauthobjectoptional
- principal_idstring
- principal_kindagentservice_accountuserThe kind of principal an access token authenticates as.
- resource_scope_idobjectoptional
- resource_scope_typeobjectoptional
- tokenstringThe plaintext token. Store it securely — it is not shown again.
GET/user/tokenstoken:read
List the authenticated user's personal access tokens
Token scope
token:read
Send
Query
- limitint32optionalPage size (1-100)
- next_tokenstringoptionalPagination cursor
Refuses
401Unauthorized403Forbidden — the caller's access level or token scope is insufficient
Receive
items[]object10 fields
- access_token_iduuid
- created_atstring
- expires_atobjectoptional
- last_used_atobjectoptional
- namestring
- oauthobjectoptional
- principal_idstring
- principal_kindagentservice_accountuserThe kind of principal an access token authenticates as.
- resource_scope_idobjectoptional
- resource_scope_typeobjectoptional
- next_tokenobjectoptional
DELETE/user/tokens/{token_id}token:manage
Revoke one of the authenticated user's personal access tokens
Token scope
token:manage
Send
Path
- token_iduuidAccess token ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthorized403Forbidden — the caller's access level or token scope is insufficient404Token not found
Receive
No body.
POST/service-accounts/{sa_id}/tokenstoken:manage
Create an access token for a service account
Token scope
token:manage
Counts against
tokens_per_principal
Send
Path
- sa_iduuidService account ID
Body
- expires_in_daysint32optionalDays until the token expires. Omit for a non-expiring token.
- namestring
- resourceobjectoptional
Refuses
400Invalid request401Unauthenticated — no credential, or an invalid one403Forbidden404Service account not found409The request conflicts with the resource's current state; `reason` says how429Token limit reached for this service account
Receive
The same shape create_personal_token returns — 2 fields. Go to it
Show it here
access_tokenobject10 fields
- access_token_iduuid
- created_atstring
- expires_atobjectoptional
- last_used_atobjectoptional
- namestring
- oauthobjectoptional
- principal_idstring
- principal_kindagentservice_accountuserThe kind of principal an access token authenticates as.
- resource_scope_idobjectoptional
- resource_scope_typeobjectoptional
- tokenstringThe plaintext token. Store it securely — it is not shown again.
GET/service-accounts/{sa_id}/tokenstoken:read
List a service account's access tokens
Token scope
token:read
Send
Path
- sa_iduuidService account ID
Query
- limitint32optionalPage size (1-100)
- next_tokenstringoptionalPagination cursor
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden404Service account not found
Receive
The same shape list_personal_tokens returns — 2 fields. Go to it
Show it here
items[]object10 fields
- access_token_iduuid
- created_atstring
- expires_atobjectoptional
- last_used_atobjectoptional
- namestring
- oauthobjectoptional
- principal_idstring
- principal_kindagentservice_accountuserThe kind of principal an access token authenticates as.
- resource_scope_idobjectoptional
- resource_scope_typeobjectoptional
- next_tokenobjectoptional
DELETE/service-accounts/{sa_id}/tokens/{token_id}token:manage
Revoke a service account's access token
Token scope
token:manage
Send
Path
- sa_iduuidService account ID
- token_iduuidAccess token ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden404Token not found409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
Limits
100 tokens per identity. A user holds at most 100 personal tokens; a service account holds at most 100 of its own. Tokens AI apps received when you connected them by signing in are not counted here; connected apps have their own limit, in Connect an AI app by signing in. It is a flat platform ceiling, identical on every plan, and it is counted per identity rather than per namespace — the tokens your bots hold do not count against yours, and yours do not count against your organisation's.
| What | Limit |
|---|---|
| Tokens per user | 100 |
| Tokens per service account | 100 |
| Token name | 1 to 100 characters |
| Scopes per token | at least one; no upper bound short of the whole catalogue |
| Expiry | any whole number of days, or none. 0 is accepted and mints a token that has already lapsed, so the smallest useful value is 1 |
| Tokens per page when listing | 20 by default, 100 at most |
At the cap the mint call is refused with 429 TOO_MANY_REQUESTS and the message Access token limit reached for this principal. Nothing is created — there is no half-minted token and no secret in flight, so retrying after you revoke something is safe. Revoke expired and unused tokens rather than asking for a higher ceiling: a hundred live credentials for one identity is already a sign that rotations are not being cleaned up.
The limit is not a plan limit and does not change when you upgrade. If an automation genuinely needs more than a hundred distinct credentials, it wants more than one service account, each holding the tokens for its own job.
Troubleshooting
400 BAD_REQUESTmentioning sa_id or token_id
- Cause
- The id in the URL is not a well-formed id — usually a name or a path pasted where an id belongs
- What to do
- Take the id from the listing. A service account and a token are only ever addressed by id, never by name
400 BAD_REQUEST— token name must be 1..=100 characters
- Cause
- The name is empty, only whitespace, or longer than 100 characters. Surrounding whitespace is trimmed before the length is measured
- What to do
- Send a name of 1 to 100 characters. Any characters are allowed, spaces included, so the failure is length or emptiness rather than the alphabet
400 BAD_REQUEST— unknown scope: <what you sent>
- Cause
- One of the scope strings is not in the catalogue — a typo, a made-up verb, or a bundle name spelled wrong
- What to do
- Fix the one the message names, checking it against the scope on the operation's card. Every scope is
domain:verb, lowercase, with an underscore in multi-word domains such asmerge_request:merge; the four bundle names carry no colon
400 BAD_REQUESTmentioning at least one scope
- Cause
- The
scopeslist was sent empty - What to do
- Name at least one scope or one bundle. There is no such thing as a scopeless token, and an empty list is refused rather than treated as read-only
400 BAD_REQUEST— Service account / pipeline run callers are not supported for this operation
- Cause
- A service account's token, or a pipeline's own run credential, was used to mint a token or to reach the personal-token calls, all of which belong to a person
- What to do
- Run the call as a person, or from a personal token. A bot's token can list and revoke a service account's tokens where its own access level allows, but neither a bot nor a pipeline can mint one
400 BAD_REQUESTmentioning the pagination cursor
- Cause
- The
next_tokenvalue was edited, truncated, or carried over from a different listing - What to do
- Start the listing again without a cursor and follow the values it returns
401 UNAUTHORIZED— Missing Authorization header
- Cause
- The request carried no credential at all
- What to do
- Send
Authorization: Bearer <token>, or setGITMOAP_TOKEN. None of these calls answer an anonymous caller
401 UNAUTHORIZED— Invalid access token
- Cause
- The value is malformed, was revoked, or never existed. Every one of those gives the same message on purpose, so nobody can use the API to find out which tokens exist
- What to do
- Check the value was copied whole, including its
gmp_/gms_prefix and with no trailing whitespace or line break. If it looks right, assume it was revoked and mint a new one
401 UNAUTHORIZED— Access token expired
- Cause
- The token passed its expiry date. This message is distinct from the one above precisely so you can tell rotation from breakage
- What to do
- Mint a replacement and deploy it. You only ever see this message while holding the correct secret, so nothing about it is a sign of an attack
403 INSUFFICIENT_SCOPE
- Cause
- The token is valid but was minted without the scope this operation needs. The response body names the missing scope under
required_scope - What to do
- Mint a new token carrying it. Scopes cannot be added to an existing token, and a bundle expanded at mint time does not grow later
403 FORBIDDEN— Access token is not authorized for this resource
- Cause
- The token is bound to a different group or repository, or to one that has since been deleted
- What to do
- Use a token bound to this resource, or an unbound one; a binding cannot be widened, so the fix is a replacement. To tell the two apart, list your tokens: a deleted target leaves the row with a
resource_scope_idand an emptyresource_scope_type
403 FORBIDDENon a service account's tokens
- Cause
- Minting, listing and revoking a service account's tokens needs Maintainer or Owner on the group or repository that owns the account
- What to do
- Ask an Owner of that group or repository to raise your access level, or to do it for you. Standing elsewhere in the tree does not carry over
403 FORBIDDENwhen binding a new token to a group or repository
- Cause
- The credential you are minting with is itself bound somewhere else, and the binding is checked before anything else — you cannot point a new token outside the subtree the old one is confined to
- What to do
- Mint from an unbound credential: your browser session, or a personal token with no binding. A bound token cannot widen its own reach through a second token
404 NOT_FOUNDwhen revoking a token you are certain exists
- Cause
- The token belongs to a different identity, or you are revoking a service account's token through the wrong account
- What to do
- Take the id from the listing for the identity that owns it — your own token list for a personal token, that account's list for a service-account one. Another identity's token answers
404rather than403, so its existence stays private
404 NOT_FOUNDon a service account you are sure exists
- Cause
- The account was deleted, or it belongs to a private group or repository you are not a member of
- What to do
- Open that group or repository's service-account list to see whether the account is still there. If you cannot open the group or repository at all, ask an Owner to add you — a private one answers
404rather than403to a non-member, so the two cases look identical from outside
429 TOO_MANY_REQUESTS— Access token limit reached for this principal.
- Cause
- The identity already holds 100 tokens
- What to do
- Revoke expired and unused ones. Nothing was created by the refused call, so retrying afterwards is safe
The token was copied but every call returns 401
- Cause
- The value was truncated, wrapped across lines by a terminal or a chat client, or stored with a trailing newline
- What to do
- Copy it again from the response, or mint a fresh one. The secret was never stored, so a partially captured token is unrecoverable
git clone, git fetch or git push fails with Access token is missing the `git:read` scope (or git:write)
- Cause
- The token carries API scopes but not the git ones. Git is its own domain, and no repository bundle includes it
- What to do
- Mint a replacement naming
git:read, andgit:writeas well if it has to push — a push needs both, because git discovers the refs before it sends anything
The token authenticates, but a push is rejected
- Cause
- The scope is right and the access level is not — pushing needs Developer, or Maintainer on a protected branch
- What to do
- Raise the principal's access level on the repository. Adding
git:writeto a token does not raise the level it acts with
A token that worked for months now fails on one new operation
- Cause
- It was minted with a bundle, and the operation's scope was added to the platform after that
- What to do
- Mint a replacement with the same bundle, which expands to today's catalogue, then revoke the old one
A bound token works everywhere except one public repository
- Cause
- The binding is checked before public visibility, so a bound token is refused outside its subtree even on repositories anyone could read
- What to do
- Use an unbound token for cross-repository work, or bind to a group high enough to contain everything the job touches
The last-used timestamp looks stale on a token you know is in use
- Cause
- It is refreshed at most once an hour to keep the authentication path fast
- What to do
- Treat it as a sign of life over the last hour, not a per-request log. For an exact record of what the token changed, cloned or fetched, read the audit log instead
Nobody can find the plaintext of a token that is still in the list
- Cause
- The plaintext is shown once at creation and only its hash is kept
- What to do
- Mint a replacement, deploy it, then revoke the old one. There is no recovery path, by design
Deleting a service account broke several jobs at once
- Cause
- Deleting an account revokes every token minted for it, in the same instant
- What to do
- Recreate the automation against a new account and new tokens. To pause a bot reversibly, revoke its tokens and keep the account
Examples
A read-only CI token bound to one repository
Mints a 90-day token that can clone the repository and report build results, and nothing else, anywhere else.
REPO_ID=$(gitmoap repos get-by-path --full-path acme/platform/api-gateway --output json | jq -r '.repository_id') gitmoap access-tokens create-personal-token \ --name "api-gateway CI" \ --scopes '["git:read","commit_status:write"]' \ --expires-in-days 90 \ --resource "{\"scope_type\":\"repository\",\"scope_id\":\"$REPO_ID\"}" \ --output json | jq -r '.token'Put that value into your CI secret store as GITMOAP_TOKEN. The job clones with it and posts its result; pointed at any other repository, the same token is refused.
Audit your own tokens
Lists what you hold with the three fields that decide whether a token should still exist.
gitmoap access-tokens list-personal-tokens --limit 100 --output json \ | jq -r '.items[] | [.access_token_id, .name, (.expires_at // "never"), (.last_used_at // "never used"), (.scopes | join(","))] | @tsv'Anything reading never in the expiry column deserves an expiry, and anything reading never used deserves revoking. Revoke by the id in the first column:
gitmoap access-tokens revoke-personal-token --token-id <token-id>FAQ
Can I see a token's value again?
No. Only a hash is kept, so the plaintext is unrecoverable the moment you navigate away. Mint a replacement and revoke the old one.
Can I add a scope to an existing token?
No. Scopes, the binding and the expiry are all fixed at mint time. Rotation is the mechanism for every change, which is why doing it in the safe order is worth learning once.
Should I use a personal token or a service-account token?
Personal for things acting on your behalf — your laptop, a script you run yourself. Service-account for anything shared or long-lived, so the credential does not die with your account and its activity is attributed to the bot rather than to you.
What happens to my tokens if I leave the organisation?
They act as you, so they lose access exactly as you do when your memberships are removed. That is the argument for putting shared automation on a service account rather than on a person.
Does a token bypass branch protection or a merge-request approval rule?
No. A token narrows what a principal may attempt; it never widens what that principal is allowed to do. Every rule that applies to the person or the bot applies to the token.
Can I use one token for both the API and git?
Yes — it is one credential. Give it git:read and git:write alongside its API scopes and it works over both.
What happens if I delete the repository a token is bound to?
The token survives and stops being useful: every call that names a group or repository is refused with 403 FORBIDDEN. Revoke it.
Are tokens counted against my plan?
No. The 100-per-identity ceiling is a flat platform limit, and upgrading a plan does not change it.
Related
- Service accountscreating the bot identity a
gms_token acts as, and the access level that bounds it - Groupsthe tree a group binding follows, and the memberships that decide what a token can reach
- Repositoriesrepository visibility and membership, the third gate on every call a token makes
- Single sign-onenforcing your identity provider for people, and where tokens sit relative to that
- Audit logthe record of every mint, revocation and token-list read, and of everything a token went on to do
- Connect an AI app by signing inthe tokens AI apps receive when you connect them, and disconnecting one
- Cloud authentication from a pipelinethe way to give a pipeline access to your cloud account without a long-lived credential at all
- Package registriespublishing and installing packages, where a
gmp_/gms_token is the registry password