Skip to content
GitmoapdocsBeta
Guide· How do I…?

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

Quick start3 steps

Create an access token

You’ll need a signed-in account — a token is always minted by a person.
  1. Open your avatar menu, choose Settings, then Access tokens, then New token.

  2. Name it after what will hold it, tick only the scopes it needs, and set an expiry.

  3. 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 login once and the stored login covers it. From the API, a Cognito session token or an existing access token carrying token:manage both work.
  • A token with the right scope, if you are scripting this rather than clicking: token:read to list tokens, token:manage to 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 resource

The 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 doScopes to give it
Clone and fetch over gitgit:read
Push over gitgit:read, git:write
Read repositories, branches, commits and file contentsrepo:read, branch:read, commit:read, tree:read
Commit file changes, or resolve a merge request's conflicts, through the APIcommit:create
Open and update merge requestsmerge_request:create, merge_request:read, merge_request:update
Comment on a merge requestmr_comment:create, mr_comment:read
Report a build result against a commitcommit_status:write
Start a pipeline and watch itpipeline:trigger, pipeline:read
Read and file issuesissue:read, issue:create
Cut a release and upload its assetsrelease:create, release:read
Publish and install packagespackage:read, package:write
Manage its own credentialstoken: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.

BundleWhat it expands to
read_apievery scope in the catalogue whose verb is exactly read
write_apievery scope in the catalogue except the five below that no bundle carries
read_repositoryevery :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_repositoryevery scope of those same domains, reads included, plus the three that have no :read scope to give: work-item reactions, labels and milestones
mcp_agentthe 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_writeeverything 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
└── billing

A 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.

Terminalbash
gitmoap access-tokens create-personal-token --name <name> --scopes '<json>'
curlbash
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.

shellscript
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:

shellscript
curl -H "Authorization: Bearer gmp_..." https://api.gitmoap.com/user/tokens

From the CLI, either pass it per command or put it in the environment, which takes precedence over any stored login:

shellscript
export GITMOAP_TOKEN="gmp_..."
gitmoap repos list --group-path acme/platform --output json

Over git, the remote helper reads the same variable, and a token set this way is used exactly as given and never refreshed:

shellscript
export GITMOAP_TOKEN="gms_..."
git clone gitmoap://acme/platform/api-gateway

Git 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.

Terminalbash
gitmoap access-tokens list-personal-tokens
curlbash
curl -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.

Terminalbash
gitmoap access-tokens revoke-personal-token --token-id <id>
curlbash
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.

Terminalbash
gitmoap access-tokens create-sa-token \
--sa-id <id> \
--name <name> \
--scopes '<json>'
curlbash
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.

Terminalbash
gitmoap access-tokens list-sa-tokens --sa-id <id>
curlbash
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.

Terminalbash
gitmoap access-tokens revoke-sa-token --sa-id <id> --token-id <id>
curlbash
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:

  1. Mint the replacement with the scopes, binding and expiry you want. Two working tokens at once is fine and is the whole point.

  2. Deploy the new value everywhere the old one lives — CI secrets, deploy hosts, the environment of anything long-running.

  3. 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.

  4. 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

create_personal_tokenPOST /user/tokensOpen in the API reference

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 request
  • 401Unauthorized
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 409The request conflicts with the resource's current state; `reason` says how
  • 429Token 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

list_personal_tokensGET /user/tokensOpen in the API reference

Token scope

token:read

Send

Query
  • limitint32optionalPage size (1-100)
  • next_tokenstringoptionalPagination cursor

Refuses

  • 401Unauthorized
  • 403Forbidden — 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

revoke_personal_tokenDELETE /user/tokens/{token_id}Open in the API reference

Token scope

token:manage

Send

Path
  • token_iduuidAccess token ID

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthorized
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Token not found

Receive

No body.

POST/service-accounts/{sa_id}/tokenstoken:manage

Create an access token for a service account

create_sa_tokenPOST /service-accounts/{sa_id}/tokensOpen in the API reference

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 request
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Service account not found
  • 409The request conflicts with the resource's current state; `reason` says how
  • 429Token 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

list_sa_tokensGET /service-accounts/{sa_id}/tokensOpen in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Service 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

revoke_sa_tokenDELETE /service-accounts/{sa_id}/tokens/{token_id}Open in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Token not found
  • 409The 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.

WhatLimit
Tokens per user100
Tokens per service account100
Token name1 to 100 characters
Scopes per tokenat least one; no upper bound short of the whole catalogue
Expiryany 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 listing20 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 as merge_request:merge; the four bundle names carry no colon
400 BAD_REQUESTmentioning at least one scope
Cause
The scopes list 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_token value 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 set GITMOAP_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_id and an empty resource_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 404 rather than 403, 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 404 rather than 403 to 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, and git:write as 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:write to 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.

shellscript
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.

shellscript
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:

shellscript
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.

  • 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