ghp issues short-lived tokens that control what an agent can access on GitHub. Two dimensions of scoping are available — repository restrictions and permission restrictions — and both are optional.
Token Types
Proxy Tokens (ghx_)
Proxy tokens are backed by a user's GitHub OAuth credential. When an agent uses a proxy token, requests go through to GitHub using that user's access. The token holder never sees the real credential.
Create a proxy token from the web dashboard or the CLI:
ghp token create \
--repo owner/repo \
--scope contents:read,pull_requests:write \
--duration 48h
Agent Tokens (gha_)
Agent tokens are backed by a GitHub App installation rather than a specific user. They are intended for automated workflows and CI pipelines where no individual user context is appropriate.
Agent tokens require the administrator to configure a GitHub App on the server. Only administrators can create agent tokens:
ghp token create \
--app mybot \
--installation myorg \
--repos owner/repo1,owner/repo2 \
--scope contents:read,pull_requests:write
The --app and --installation flags accept human-readable names (app name
and GitHub account login respectively) and resolve to IDs automatically. The
numeric --installation-id and --app-id flags are also available.
Repository Restrictions
When a token specifies one or more repositories, ghp enforces that only those
repositories can be accessed. API requests targeting a different repository
are rejected with 403 Forbidden.
- Proxy tokens use
--repo(single repository) - Agent tokens use
--repos(comma-separated list)
If no repository is specified, the token is not restricted to any particular repository — it can access any repository the underlying credential has access to.
Permission Restrictions
Tokens can specify which operations are permitted using permission scopes. These follow the GitHub API permission model:
| Scope | Description |
|---|---|
contents:read |
Read repository contents (files, commits) |
contents:write |
Push commits, create/update files |
pull_requests:read |
Read pull requests |
pull_requests:write |
Create and update pull requests |
issues:read |
Read issues |
issues:write |
Create and update issues |
metadata:read |
Read repository metadata (always permitted) |
If no scopes are specified, the token carries the full permissions of the underlying credential without additional filtering.
Open-Scoped Tokens
When a token has neither repository nor permission restrictions, it is considered "open-scoped." Open-scoped tokens forward all requests directly to GitHub using the underlying credential's full permissions. This is useful when an agent legitimately needs broad access and scoping would be too restrictive.
Open-scoped tokens are the default for proxy tokens created without --repo
and --scope flags.
GraphQL Scope Enforcement
ghp enforces token scopes on GraphQL requests for repository-restricted
and/or permission-restricted tokens via static analysis of the incoming
query. For those scoped-token requests the body is parsed (using
github.com/vektah/gqlparser/v2) and walked to extract three things:
- Required permission scopes, derived from a curated map of
Mutation.<field>and field-name → scope mappings (e.g.Mutation.createIssue→issues:write,pullRequests→pull_requests:read). - Referenced repositories, extracted from
repository(owner, name)arguments where both arguments are string literals. - Cross-repository fields (e.g.
search,node,viewer.repositories) that can return data from outside any explicitrepository(...)selection.
The proxy uses the analysis to apply a deny-by-default policy:
- Open-scoped tokens bypass GraphQL static analysis entirely; the underlying credential's permissions remain the only enforcement layer.
- Repository-restricted tokens must reference at least one
repository(owner, name)with literal arguments, and every referenced repository must appear in the token's allowlist. Cross-repository fields are rejected outright: ghp cannot statically prove their results stay inside the allowlist. GraphQL mutations are also rejected for repository-restricted tokens — GitHub's mutation inputs identify their target with an opaque global node ID (repositoryId) rather thanowner/name, which the proxy cannot statically map to the allowlist. Use the REST API for repo-scoped writes, or use a permission-only token if mutations across multiple repositories are required. - Permission-restricted tokens must grant every scope the analysis identifies. Mutations whose name does not appear in the curated mutation map are rejected — the proxy never grants a write scope it cannot classify.
- Subscriptions are rejected unconditionally: GitHub's GraphQL endpoint does not support them over HTTP, and the proxy cannot stream-scope per-event payloads.
- Unknown object-typed fields (fields with their own selection set that do not appear in the allowlist or scope map) are rejected for any scoped token. Scalar leaves are permitted without a per-field mapping because their parent field's scope already gates them.
Variable-driven repository lookups (repository(owner: $owner, ...)) cannot
be statically validated against a token's repository allowlist. For
repository-restricted tokens, the proxy rejects any request that contains
even one such variable-driven repository(...) selection, even if other
literal allowlisted lookups are present in the same query — otherwise an
attacker could mix one literal allowlisted lookup with an unbounded
variable-driven lookup that bypasses the allowlist. Tokens with no
repository restriction (permission-only or open-scoped) are unaffected.
For requests that go through GraphQL static analysis the request body is
capped at 1 MiB to bound memory usage. Open-scoped tokens bypass static
analysis, so this cap does not apply to them.
Operators reading 403 responses with a "GraphQL request references unmapped
fields" message can use the named field as a hint for extending
fieldScopeRequirements or mutationScopeRequirements in
internal/proxy/graphql.go to cover legitimate use cases.
Known limitations
- Scalar leaves under always-allowed parents are not deny-by-default.
Without loading GitHub's full SDL the proxy cannot enumerate every
scalar field name, so unmapped scalars projected directly under an
always-allowed parent (e.g.
repository(owner, name) { someScalar }) pass static analysis. The risk is bounded by the underlying credential's permissions and by GitHub's own access checks; operators who need stricter enforcement should map the affected scalar infieldScopeRequirementsor use REST scoping for the affected flow. - Repository-restricted GraphQL mutations are not supported. GitHub
mutation inputs identify their target by opaque global node ID
(
repositoryId), which the proxy cannot statically map to a repository. Use the REST API or a permission-only token instead.
Expiration and Revocation
All tokens have a configurable lifetime. The default is 24 hours, with a server-configured maximum (default 7 days). Tokens can be revoked immediately at any time from the CLI or web dashboard — once revoked, any further requests using that token are rejected.
When the server has tokens.allow_no_expiry enabled, tokens can be created
with no expiry ("duration": "never" in the API, --duration never on the
CLI). Non-expiring tokens are useful for long-lived automation managed via
infrastructure-as-code (e.g. Terraform), where token lifecycle is controlled
through revocation rather than time-based expiry.