Keys and safekeeping
A pipeline key is a credential with a fixed list of scopes. It acts for the account that owns it, and it can be limited to one project, an expiry date, a set of addresses and credit caps.
Who may hold a key
Any active account may hold and use a key. Starting a batch needs an active plan on the owning account (Creator, Pro or Max); without one the submit answers 403 batch_subscription_required and every other route keeps working. Nothing is deleted; batches can start again once a plan is active. An inactive or suspended account gets 403 account_inactive or 403 account_frozen.
An account can hold at most 5 active keys.
Creating a key
The panel sends the grant fields below on POST /api/keys, and the rest of this section is the reference for that call. It needs a signed-in account session: an API key cannot create, rotate or revoke keys.
Send any of these fields with name to make a pipeline key: preset, scopes, workspaceId, dailyCreditCap, perBatchCreditCap, maxActiveBatches, allowedCidrs, expiresInDays. Scopes are required, directly through scopes or through a preset.
{
"name": "ci-submit",
"preset": "submit_only",
"dailyCreditCap": 100,
"perBatchCreditCap": 50,
"expiresInDays": 60,
"allowedCidrs": ["203.0.113.0/24"]
}The reply is the only time the full key is shown. It is stored as a hash and cannot be shown again.
{
"id": "...",
"name": "ci-submit",
"key": "myrl_...",
"prefix": "myrl_AbC1234",
"created_at": "...",
"message": "Save this key securely. It will not be shown again.",
"expires_at": "...",
"grant": {
"apiKeyId": "...",
"scopes": ["projects:read", "..."],
"workspaceId": null,
"dailyCreditCap": 100,
"perBatchCreditCap": 50,
"maxActiveBatches": 2,
"allowedCidrs": ["203.0.113.0/24"],
"disabledAt": null
}
}Other key routes, all session only: GET /api/keys lists keys with the prefix only, DELETE /api/keys/{id} revokes one, PATCH /api/keys/{id}/grant changes scopes, binding, caps, addresses or switches pipeline access off, POST /api/keys/{id}/rotate rotates, and GET /api/keys/{id}/audit reads the key's audit trail.
Scopes
A key can only hold scopes from this fixed list. Anything else is refused when the key is created. A route that needs a scope the key lacks answers 403 missing_scope and names the scope.
| Scope | Routes that need it |
|---|---|
projects:read | GET /notifications GET /notifications/preferences GET /projects GET /projects/{slug} GET /projects/{slug}/assets GET /projects/{slug}/assets/{assetId} GET /projects/{slug}/assets/{assetId}/history GET /projects/{slug}/progress GET /projects/{slug}/quality |
projects:write | PUT /notifications/preferences POST /notifications/read-all POST /notifications/{notificationId}/read PUT /projects/{slug} |
inputs:read | No route uses this scope yet. |
inputs:write | POST /projects/{slug}/inputs |
batches:read | GET /batches/{batchId} GET /batches/{batchId}/events GET /batches/{batchId}/wait |
batches:quote | GET /pricing POST /projects/{slug}/batches/quote POST /projects/{slug}/lint POST /projects/{slug}/resize POST /projects/{slug}/restore |
batches:submit | POST /projects/{slug}/batches |
batches:retry | No route uses this scope yet. |
batches:cancel | POST /batches/{batchId}/cancel |
results:read | GET /batches/{batchId}/preview.png GET /batches/{batchId}/receipt GET /batches/{batchId}/result-url GET /batches/{batchId}/result.json GET /batches/{batchId}/result.zip GET /files/{fileId} GET /files/{fileId}/download GET /projects/{slug}/receipt GET /projects/{slug}/thumbnails |
reviews:write | POST /projects/{slug}/evidence POST /projects/{slug}/reviews |
GET /me and GET /capabilities need no scope. A signed-in session carries every scope and no project binding or caps.
Presets
A preset expands to a scope list when the key is created. The key stores the scopes, not the preset name. Extra scopes can be added next to a preset.
| Preset | Default expiry | Needs a project | Scopes |
|---|---|---|---|
modworks | 30 days | Yes, workspaceId | projects:read, projects:write, inputs:read, inputs:write, batches:read, batches:quote, batches:submit, batches:retry, batches:cancel, results:read, reviews:write |
read_only | 90 days | No | projects:read, inputs:read, batches:read, results:read |
submit_only | 90 days | No | projects:read, inputs:read, inputs:write, batches:read, batches:quote, batches:submit, results:read |
read_onlyis for dashboards and status checks: every read scope and nothing else.submit_onlyis for a CI job that uploads inputs, lints, quotes, submits and reads results. It cannot edit projects, submit reviews, retry or cancel.modworksis the Mod Works integration key: bound to one project, 30 days by default. See Mod Works toolkit and MCP.
Limits on a key
| Field | What it does |
|---|---|
workspaceId | Binds the key to one of your projects. A bound key sees only that project; any other slug answers 404 project_not_found. |
expiresInDays | 1 to 365 days. A preset supplies a default. A key made from explicit scopes with no preset and no expiresInDays never expires, so set one. |
allowedCidrs | Up to 16 addresses or CIDR ranges. When set, a request from any other address gets 403 ip_not_allowed. An address the server cannot read is refused, not allowed. |
dailyCreditCap | Credits the key may reserve per day, up to 1,000,000. Omitted means 0. |
perBatchCreditCap | Credits one batch may cost, up to 1,000,000. Omitted means 0. |
maxActiveBatches | Batches the key may have active at once, 0 to 20. Omitted means 20. |
Sending a key
Send X-API-Key: myrl_... or Authorization: Bearer myrl_.... Sending both is 400 ambiguous_credentials. New keys are myrl_ followed by 40 random letters and digits and 6 checksum characters, 51 characters in all, matching myrl_[0-9A-Za-z]{46}. The first 12 characters are the prefix shown in lists, audit rows and notices. Older myrl_ and mk_live_ keys keep working. A key with a wrong checksum is refused before any lookup.
What Forge does to protect keys
- Hashed storage. Only a SHA-256 hash of the key is stored. Forge cannot show you a key again.
- Shown once. The full key appears in the create reply and nowhere else. Notices, logs and audit rows carry the prefix only, never the key, a key hash, an address or a URL.
- Checksummed format. A scanner can tell a real key shape from a lookalike, and Forge refuses a mistyped or made-up key without a database lookup.
- GitHub secret scanning. Forge runs a receiver for GitHub's secret scanning reports. When GitHub reports a key, Forge verifies GitHub's signature, revokes the key at once, records the report, and sends the owner a notice if the key was live.
- Rotation.
POST /api/keys/{id}/rotatemakes a new key with the same grant. The old key keeps working for 24 hours so you can switch over. - Audit view. The owner can read a key's audit rows and lifecycle events for 90 days.
- Expiry reminders. A key with an expiry gets a notice 7 days and 1 day before it stops working.
- Watching for misuse. The owner gets a notice the first time a key is used from a new network, and when a known key has 20 refused requests in 10 minutes.
- Failed guesses are throttled. 20 failed key attempts from one address in 10 minutes gets
429 too_many_failed_auth.
Notices about your keys and batches
Events create a notice on your account. GET /notifications lists them, POST /notifications/{id}/read marks one read, and PUT /notifications/preferences sets email switches. Email copies go out only where email delivery is switched on. Security notices cannot be turned off.
| Kind | Sent when | Can be turned off |
|---|---|---|
batch_finished | a batch reached completed or completed_with_failures (best-effort files ride in the same notice) | Email only |
batch_failed | a batch ended needs_review, failed or expired | Email only |
lane_paused | a fix_exhausted problem paused a lane for the account | Email only |
lane_cleared | an admin resolved the problem that paused a lane | Email only |
cap_reached | a submit was refused by a key or project credit cap | Email only |
key_created | an API key was created on the account | No |
key_revoked | an API key was revoked | No |
key_rotated | an API key was rotated (the old key keeps working for a grace period) | No |
key_expiring | an API key expires in 7 days or 1 day | No |
key_new_network | a key was used from a network it had not been used from before | No |
unusual_usage | a known key had repeated refused requests | No |
subscription_needed | a key request was refused with pipeline_subscription_required | Email only |
studio_maintenance | Studio was under maintenance while work of the account needed a Studio builder | Email only |
studio_recovered | Studio is taking new work again after a maintenance the account was told about | Email only |
Email limits: one email per kind per project per 1 hour and 10 a day per account. More than 3 finished-batch notices in 30 minutes become one digest, sent after 30 quiet minutes. Security notices skip those two limits and stop at 30 a day. No message holds a key, a prompt or file contents.
Keeping a key safe on your side
- Keep keys in environment variables or a secret manager. Never put one in a repository, a script you commit, a browser bundle or front-end code. The pipeline API is meant to be called from a server or a CI job.
- Use one key per integration, so you can revoke one without breaking the others and the audit trail shows which one acted.
- Give every key the fewest scopes it needs.
read_onlyandsubmit_onlyexist for that. - Set an expiry. Keys expire at most 365 days out, and a short life limits what a leak is worth.
- Use an address allowlist when the caller has fixed addresses.
- Rotate a key after any exposure: a laptop lost, a log shared, a teammate who left. Revoke the old one when the new one is in place.
- Do not paste a key into a ticket, a chat or a screenshot. If you did, revoke it now. Anything shaped like a key in a public repository may be reported by GitHub and revoked.
- Keep keys out of logs and error reports. The example client never prints the key.