Skip to content

R2 S3-compatible token setup (per-bucket)

ConcernDashboard clickAPI mint (this page)
Audit trailNone — secret value visible once on screen, then lostcfat_… value persisted in the PRD’s artefacts/03-mint-r2-token-response.json
Replay-abilityOperator must remember which permission groups were attached02-mint-r2-token-request.sh is the literal curl, re-runnable
RotationGuesswork: “did we use Read+Write or just Write?”Same script, new PRD, new artefact set
Provenance for wrangler secret put valueOperator-typed04-derive-s3-keys.sh is deterministic from the response

How R2 derives an S3 keypair from a CF API token

Section titled “How R2 derives an S3 keypair from a CF API token”
S3 fieldDerivation
Access Key IDresult.id from POST /accounts/{id}/tokens (32 hex chars)
Secret Access Keysha256(result.value) — SHA-256 of the raw cfat_… string (64 hex chars)

Source of truth: Cloudflare R2 docs — Get S3 API credentials from an API token.

As of 2026-05-05 (re-fetch via GET /accounts/{id}/tokens/permission_groups if older than ~12 months):

Permission groupUUID
Workers R2 Storage Bucket Item Write2efd5506f9c8494dacb1fa10a3e7d5b6
Workers R2 Storage Bucket Item Read6a018a9f2fc74eb6b293b0c548f38b39

Both are required for a bucket whose Worker needs both PutObject and GetObject (the common case — presigned-URL minters need to read what they wrote).

  1. Mint the per-bucket CF API token. POST /accounts/{id}/tokens with both perm-group IDs and resources pinned to com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket>. Capture the response as docs/agents/PRD-<N>/artefacts/03-mint-r2-token-response.json.
  2. Derive the S3 keypair. Access Key ID = result.id, Secret Access Key = sha256(result.value). Script: 04-derive-s3-keys.sh.
  3. SigV4 smoke-test. aws s3api head-bucket --endpoint-url https://<account_id>.r2.cloudflarestorage.com --bucket <bucket> — exit 0 = good.
  4. Push to Worker secrets with wrangler secret bulk against every Worker that needs the keypair (e.g. PRD-223 pushed to both api-worker AND n1-mcp). Same JSON, two invocations.
  5. Mirror to local .dev.vars with a provenance comment block pointing at the per-bucket maintenance playbook.

Every mint exercise drops the following under docs/agents/PRD-<N>/artefacts/:

FileCaptures
00-README.mdCritical-facts table (account id, bucket, both perm-group IDs, both keys, parent token id, mint date), index, rotation pointer
01-cf-permission-groups.jsonPinned permission-group rows
02-mint-r2-token-request.shVerbatim curl to POST /tokens
03-mint-r2-token-response.jsonVerbatim CF API response (contains the cfat_… token VALUE)
04-derive-s3-keys.shReproducible derivation script
05-verify-head-bucket.shThe SigV4 smoke test
06-wrangler-secret-bulk.jsonVerbatim JSON fed to wrangler secret bulk
07-wrangler-secret-bulk.shThe two wrangler secret bulk invocations

The repo owner explicitly authorises plaintext credential storage in docs/**/*.md (see docs/cloudflare-account-setup.md §“Token storage”). The first complete instance is docs/agents/PRD-223/artefacts/ — copy that file shape verbatim for any future bucket.

The first instance shipped 2026-05-05 alongside PRD-223 (n1-feedback-screenshots). The artefacts contain the live keypair that api-worker + n1-mcp use today to mint 15-minute presigned-GET URLs from n1_admin_feedback_list and the admin web UI:

  • Account: dc59d537ca69e32f136f8ab457ce1a7c
  • Bucket: n1-feedback-screenshots
  • Access Key ID: a9c0e0df8369cb59526e282b69b353ab
  • Parent CF API token used to mint: the “n1-cloud token” (cfat_vfL…91) in packages/api-worker/.dev.vars — given Account API Tokens: Read & Write scope on 2026-05-05.
TriggerAction
YearlyRe-run §1–§5 with a fresh PRD number; revoke old token via DELETE /accounts/{id}/tokens/{old_id} after ≥ 15 min (wait for in-flight presigned URLs to expire).
Suspected leakSame as yearly, but immediate. The 15-minute wait still applies — already-issued presigned URLs survive past the wrangler-secret swap.
Parent CF API token revoked / offboardedRe-mint from a fresh parent first; only then destroy the old parent.
SymptomCauseFix
POST /tokens → 9106 Invalid permission groupsCF rotated the perm-group UUIDsRe-fetch from GET /accounts/{id}/tokens/permission_groups.
head-bucket → AccessDeniedresources path doesn’t match the bucketLiteral value: com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket>.
head-bucket → SignatureDoesNotMatchPasted token id where secret should goRe-derive per Step 2.
wrangler secret bulk → worker not found--env=production against a Worker without that env in its wrangler.jsoncDrop --env.