Personal Access Tokens
A Personal Access Token (PAT) is a secret string that lets a script or tool act as you against the Manager API. It replaces the browser login for automation: the CLI, CI jobs, or a GraphQL client. A PAT has exactly the permissions of the user who created it, and only that user can see or revoke it.
For a service that is not tied to a person, use a machine user instead.
Key facts
- Format:
pat_<prefix>_<random>. The prefix is 8 characters and is stored in plain text so the Manager can find the token quickly and you can recognise it in the UI. The random part is 43 characters. - Storage: only a PBKDF2-SHA256 hash is stored (600,000 iterations, a random 16-byte salt per token). The plaintext is shown once, at creation.
- Expiry is optional. Revocation takes effect immediately.
- Each use updates
lastUsedAt. The write happens in the background, so it can lag by a moment. - API calls must go through the proxied entrypoint (the URL you use in the browser), not the Manager's own port. The Manager checks a shared secret from the proxy before it looks at any credential.
The CLI can create tokens for you
pfy login --server <url> opens a browser login and creates a PAT named pfy/<user>@<host>/<timestamp> that expires after 90 days. It appears in your token list and you can revoke it there. For CI, create a token in the UI and pass it with pfy login --token-stdin. See the CLI login docs and Browser login for the CLI below.
Creating a token
In the UI
- Click Personal Access Tokens in the sidebar.
- Click Create Token.
- Enter a Token Name (up to 255 characters) and, if you want, an Expiration Date.
- Click Create.
- Copy the token from the Token Created Successfully dialog, then click I've saved my token.
The token is shown only once
You cannot retrieve it later. If you lose it, revoke it and create a new one.
With the API
mutation {
createPersonalAccessToken(
name: "GitHub Actions Deployment"
expiresAt: "2025-12-31T23:59:59Z"
) {
token
personalAccessToken {
id
name
tokenPrefix
createdAt
expiresAt
}
}
}expiresAt is optional. If you set it, it must be in the future. The name must not be empty.
Response:
{
"data": {
"createPersonalAccessToken": {
"token": "pat_AbCd1234_9f8e7d6c5b4a...",
"personalAccessToken": {
"id": "uuid",
"name": "GitHub Actions Deployment",
"tokenPrefix": "AbCd1234",
"createdAt": "2025-12-01T10:00:00Z",
"expiresAt": "2025-12-31T23:59:59Z"
}
}
}
}Using a token
Send it in the Authorization header. In the examples, https://manager.example.com is the address you open the Manager with in the browser (the proxy), not the Manager's internal address.
curl
curl -H "Authorization: Bearer pat_AbCd1234_9f8e7d6c5b4a..." \
-H "Content-Type: application/json" \
-d '{"query":"{ myPersonalAccessTokens(filters: [], order: {field: \"createdAt\", direction: DESC}, pagination: {limit: 5, offset: 0}) { name lastUsedAt } }"}' \
https://manager.example.com/queryGraphQL client (JavaScript)
import { createClient, cacheExchange, fetchExchange } from "@urql/core";
const client = createClient({
url: "https://manager.example.com/query",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: {
headers: {
Authorization: `Bearer ${process.env.PAT_TOKEN}`,
},
},
});CLI
# Read the token from stdin so it stays out of `ps` and shell history.
printf '%s' "$PAT_TOKEN" | pfy login --server https://manager.example.com --token-stdin
# Then use the CLI normally.
pfy project listManaging tokens
Listing
UI: the Personal Access Tokens page. Columns: Name, Token (the prefix), Last Used, Expires, Status (Active or Revoked), Created at, Actions.
API:
query {
myPersonalAccessTokens(
filters: []
order: { field: "createdAt", direction: DESC }
pagination: { limit: 20, offset: 0 }
) {
id
name
tokenPrefix
createdAt
expiresAt
revoked
lastUsedAt
}
}Filter fields: name, revoked, tokenPrefix. Sort fields: name, createdAt, lastUsedAt. Other names are rejected with "invalid order field". The query returns only your own tokens.
Revoking
Revoking stops the token immediately. The record stays in the list, marked Revoked, so you keep the history.
UI: click the ban icon on the row and confirm. To revoke every active token at once, click Revoke all at the top of the page.
API:
mutation {
revokePersonalAccessToken(id: "token-uuid") {
id
revoked
}
}mutation {
revokeAllPersonalAccessTokens
}revokeAllPersonalAccessTokens returns the number of tokens it revoked.
Deleting
Deleting removes the record.
UI: click the trash icon on the row and confirm.
API:
mutation {
deletePersonalAccessToken(id: "token-uuid")
}Expiry
- With
expiresAt: after that time the token is rejected with the error codetoken_expired, and the UI shows an Expired chip. - Without
expiresAt: the token works until you revoke or delete it.
Set an expiry for anything short-lived, such as a contractor's access or a one-off migration. Non-expiring tokens are acceptable for long-running automation if you review the list regularly and check lastUsedAt.
Browser login for the CLI (device flow)
pfy login without a token uses a device-authorization flow:
- The CLI calls
POST /api/cli/login/startwith a fresh public key and a token name. It receives a session id, a verification URL and a short user code. Start and poll are reachable only through the proxy and need no user credential. - You open the URL (
/cli/authorize?session=...), sign in through the normal browser login, check that the code on the page matches your terminal, and click Approve. - The Manager creates a PAT for you with a 90-day expiry, encrypts it to the CLI's key, and stores only the encrypted blob.
- The CLI polls
POST /api/cli/login/pollevery 5 seconds and receives the blob exactly once. A session expires after 10 minutes.
The token is recorded in the audit log like any other and can be revoked from your token list.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
401 with error code invalid_token | The token is malformed, unknown, or revoked. Check you copied the whole string including pat_. |
401 with error code token_expired | expiresAt has passed. Create a new token. |
401 "missing or invalid X-Proxy-Auth header: this endpoint is reachable only through the trusted proxy" | You called the Manager's own address. Use the proxied URL. |
401 "no authenticated user: the request reached the manager without the X-Token-Subject ... headers" | Same cause: the proxy was bypassed, so it never injected the identity headers. |
| "invalid order field" | Use createdAt, name or lastUsedAt in order.field. |
Comparison with machine users
| Feature | Personal Access Tokens | Machine users |
|---|---|---|
| Scope | One user | One environment of a project |
| Expiry | Optional | None; a sweep reports credentials past the 90-day policy |
| Auth | Bearer only | Bearer; Basic is also accepted through the proxy |
| Typical use | CLI, scripts, CI | Backend services, trigger callbacks |
| Created by | You, for yourself | Project members |