Give CI workspace access without sharing your cookie.
A Personal Access Token (PAT, prefix `pcft_live_*`) is the right shape for any non-human caller: deploy pipelines, infra-as-code, scheduled exporters, admin scripts.
1
When to mint a PAT
- Deploy pipeline needs to provision an Auth app for the new environment + push templates.
- Infra-as-code (Terraform, Pulumi) manages workspaces + roles declaratively.
- Scheduled job exports the message log nightly for compliance / analytics.
- Admin script a human runs from their laptop but doesn't want their cookie embedded in the script.
Don't mint a PAT for:
- End-user identity. Customers' end- users go through Auth, not the Platform API.
- Day-to-day operator workflows. Sign in to the console; that's what it's for.
2
Mint a PAT
PATs bind to one or more managed workspace policies(rows in workspace_policy). Author the policy first, then reference its id when minting. The two-step shape — instead of an inline policy on mint — means a teammate can rotate the PAT without re-asserting the permission set, and a single policy can be reused across many PATs and roles.
curl https://api.platform-auth.productcraft.co/v1/workspaces/acme/policies \
-H 'authorization: Bearer <cookie-or-platform-jwt>' \
-H 'content-type: application/json' \
-d '{
"name": "ci-staging-deploy",
"description": "Auth app provisioning + Mail template ops for CI",
"policy": [
{
"effect": "allow",
"actions": ["auth.create", "auth.read", "auth.update", "mail.create", "mail.update", "mail.read"],
"resources": ["*"]
}
]
}'
# → { "id": "<policy-uuid>", "workspace_id": "...", ... }
curl https://api.platform-auth.productcraft.co/v1/workspaces/acme/api-keys \
-H 'authorization: Bearer <cookie-or-platform-jwt>' \
-H 'content-type: application/json' \
-d '{
"name": "github-actions-staging-deploy",
"description": "Deploys acme-staging app from CI",
"policy_ids": ["<policy-uuid-from-step-1>"]
}'Response — the only time the plaintext token is returned:
{
"token": "pcft_live_thatappearsonce...",
"record": {
"id": "<pak-uuid>",
"workspace_id": "<workspace-uuid>",
"created_by": "<account-uuid>",
"name": "github-actions-staging-deploy",
"description": "Deploys acme-staging app from CI",
"token_prefix": "pcft_live_thata",
"policies": [
{ "id": "<policy-uuid>", "name": "ci-staging-deploy", "description": "..." }
],
"last_used_at": null,
"revoked_at": null,
"created_at": "2026-05-25T...",
"updated_at": "2026-05-25T..."
}
}Platform-Auth stores only the hash. No recovery — if you lose the secret, rotate (Section 5) and update your secret store. Treat the token like a password.
Mint requires a human session. PAT mint/update/revoke is cookie/JWT-only on api.platform-auth.productcraft.co — a PAT cannot mint another PAT. Same for workspace create/delete and role/policy/invite mutations. Reads and PAT-introspect (the operations downstream services and pipelines need) do work with a PAT bearer.
3
Caller-narrowing on mint
You cannot grant a PAT permissions you don't yourself hold.
Same shape as role authoring (Chapter 2 — Section 5), and enforced wherever a policy is authored or bound: an admin (excluded from workspace.delete) can't author or bind a policy with workspace.delete for a PAT. Trying returns 403 Cannot grant actions you don't have: workspace.delete.
Implication for CI: if your pipeline needs broad permissions, mint the PAT from an owner account, not from a least-privilege admin. The PAT inherits the minter's authority cap.
4
Scope tightly
Patterns for common pipelines:
{
"effect": "allow",
"actions": ["workspace.audit.read", "mail.message.read"],
"resources": ["*"]
}{
"effect": "allow",
"actions": ["auth.update", "auth.read"],
"resources": ["pcft:heimdall:app/<app-uuid>"]
}
// App resources are scoped by app *id*, not slug. A provisioner that
// creates new apps needs "auth.create" on resources ["*"].[
{
"effect": "allow",
"actions": ["mail.*"],
"resources": ["*"]
},
{
"effect": "deny",
"actions": ["mail.delete"],
"resources": ["*"]
}
]Explicit deny wins. The template-deploy PAT above can create, update, send, but not delete — even if a teammate later widens mail.* to include something destructive.
5
Rotate
Periodic rotation (90 days is a reasonable default) + on-suspicion rotation when a former teammate leaves or a secret might have leaked.
# Mint replacement first — re-uses the same managed policy from Section 2.
curl -X POST .../v1/workspaces/acme/api-keys \
-H 'authorization: Bearer <admin-or-owner-cookie-or-jwt>' \
-d '{ "name": "github-actions-staging-deploy-v2", "policy_ids": ["<policy-uuid>"] }' > new.json
# Update CI secret store with the new value
# After CI is using the new PAT, revoke the old
curl -X DELETE .../v1/workspaces/acme/api-keys/<old-id> \
-H 'authorization: Bearer <admin-or-owner-cookie-or-jwt>'Revocation is immediate at the source, but services that accept PAKs cache introspection results for up to 60 seconds — a revoked PAK can still authenticate against a cached entry for that window. Plan rotations assuming a one-minute tail.
6
Audit usage
Two signals. First, last_used_at on the PAT record (GET .../api-keys, also shown in the console) — PATs with no usage in > 30 days are candidates for cleanup. Second, the workspace audit log: PAT lifecycle events (platform.apikey.created, platform.apikey.revoked, …) and Platform API actions performed with the PAT are recorded with the key's id as the actor and the caller IP. Per-service activity (Mail sends, Auth app changes, …) lives in each service's own logs — see Chapter 5.
curl 'https://api.platform-auth.productcraft.co/v1/workspaces/acme/audit-logs?limit=100' \
-H 'authorization: Bearer <token-or-pak>' \
| jq --arg id "<pak-id>" '.data[] | select(.actor_id == $id or .resource_id == $id)'7
Where to store the secret
- GitHub Actions: repo or org-level secret, accessed via
${{ secrets.PRODUCTCRAFT_PAT }}. - GitLab CI: CI/CD variables, masked.
- Vault / Doppler / AWS Secrets Manager: the right choice if you have multiple environments + CI + production services all needing tokens.
- .env files committed to a repo: never. Even private repos. The audit trail of who-saw-what is hopeless once it lands in git.
8
Provisioning script — a complete example
name: Provision staging
on:
workflow_dispatch:
env:
PAT: ${{ secrets.PRODUCTCRAFT_PAT }}
WORKSPACE_ID: <workspace-uuid>
jobs:
provision:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Auth app for staging
run: |
curl -fsS https://api.auth.productcraft.co/v1/apps \
-H "authorization: Bearer $PAT" \
-H 'content-type: application/json' \
-d '{ "slug": "acme-staging", "display_name": "Acme (staging)",
"workspace_id": "'"$WORKSPACE_ID"'" }'
- name: Sync role catalogue
run: |
curl -fsS https://api.auth.productcraft.co/v1/apps/<app-id>/roles \
-H "authorization: Bearer $PAT" \
-H 'content-type: application/json' \
-d '{ "name": "viewer", "description": "Read-only" }' || true
- name: Push Mail templates from repo
# POST /templates is an idempotent upsert on (workspace, name)
run: |
for t in templates/*.json; do
curl -fsS "https://api.mail.productcraft.co/v1/workspaces/$WORKSPACE_ID/templates" \
-H "authorization: Bearer $PAT" \
-H 'content-type: application/json' \
-d @"$t"
doneOne PAT, narrowly scoped (auth.create + auth.read + mail.create + mail.update on the workspace), workflow-dispatch only. Template files are the create body: { "name": "...", "subject": "...", "body_html": "..." }.