HonorBox

Least privilege: the two keys, scoped honestly

"You want my Stripe secret key in a GitHub Action?" No. HonorBox never needs your full secret key, and it never needs a broad GitHub token. Delivering a sale is two calls, both visible in scripts/fulfill.js (392 lines, read it):

CallWhySecret
GET /v1/checkout/sessions (list, expanding line items)find new paid checkoutsSTRIPE_SECRET_KEY
PUT /repos/{repo}/collaborators/{user} (read-only invite)deliver the productGH_FULFILL_TOKEN

Keeping that sale delivered adds three more, in scripts/renew-invites.js. GitHub expires an unaccepted invitation after seven days, so the fulfillment workflow re-issues one before that happens (how-it-works):

CallWhySecret
GET /repos/{repo}/invitationsfind invitations about to lapseGH_FULFILL_TOKEN
PUT /repos/{repo}/collaborators/{user}re-issue, restarting the 7-day clockGH_FULFILL_TOKEN
DELETE /repos/{repo}/invitations/{id}remove the invitation it just supersededGH_FULFILL_TOKEN

No Stripe key is involved: renewal never looks at money, so the step that runs it is not given one. The same file's --revoke, which you run by hand after a refund, additionally calls DELETE /repos/{repo}/collaborators/{user}.

Selling a subscription adds its own calls on top, in scripts/reconcile-subs.js. That feature makes no call at all until you configure it (subscriptions.md).

Both providers support scoping a credential down to exactly that. This page gives the exact toggles, what breaks if you cut too deep, and what a leaked key could and could not do. Set up this way, the scary-sounding secret in your Actions repo is a read-only sales viewer and a single-repo repo admin, not your Stripe account and not your GitHub account.

The Stripe key: a restricted key, read-only

Stripe restricted API keys (prefix rk_live_) carry only the permissions you pick, one setting per resource: None, Read, or Write. Stripe's own guidance is to prefer them over secret keys everywhere. The fulfillment poll only ever issues GET requests, and in Stripe's model GET maps to Read. The key needs write access to nothing.

Create it: Dashboard → Developers → API keys → Create restricted key, start from zero permissions, then set exactly one resource:

Put that rk_live_… value in the ops repo's Actions secret STRIPE_SECRET_KEY. The engine doesn't care which key type it gets: a restricted key is a drop-in replacement.

Prove it on your own account in one run: trigger the fulfillment workflow once (Actions → Fulfill orders → Run workflow) and read the log. If a permission is missing, Stripe rejects the call and, per Stripe's docs, the error response names the permission to add; the key's request log in the Dashboard (⋯ → View request logs) shows the same thing. A green sessions scanned=… line is your proof that Read on Checkout Sessions is all this engine ever asks for. (The poll expands each session's line items; line items are part of the Checkout Sessions resource, and this one-run check verifies it against your account rather than asking you to trust us.)

The one-time init.js helper is different. It creates your Product, Price, and Payment Link, so it needs Write on those resources. Don't give the fulfillment key that power: run init.js from your laptop with a second restricted key (Products → Write, Payment Links → Write; prices are created under Products), then delete that key in the Dashboard. It never belongs in Actions secrets. Prefer zero keys? The manual Dashboard path in setup §2 needs none.

The GitHub token: fine-grained, one permission, named repos

Delivery is one call: invite the buyer to the private product repo with read (pull) permission. GitHub's fine-grained personal access tokens scope to named repositories and named permissions, and GitHub's REST permission reference maps the invite endpoint to repository Administration (write).

Create it: GitHub → Settings → Developer settings → Fine-grained tokens → Generate new token:

That token is Actions secret GH_FULFILL_TOKEN.

Two scope notes that keep this minimal:

If you over-restrict, here's what breaks

Nothing fails silently, and no sale is lost: the poll re-reads truth from Stripe on every run.

Cut too deepSymptomSale outcome
Stripe key lacks Checkout Sessions Readrun fails loudly; Stripe's error names the missing permissionnothing processed, state untouched; next good run picks everything up
PAT lacks Administration writeinvite returns 403; run logs FAILED, ledger row flagged needs_attentionorder is flagged, not retried; fix the token and invite the buyer from the repo page (ten seconds)
PAT missing the product repoGitHub answers 404 (fine-grained tokens don't see repos outside their list)same flagged path as above
PAT lacks storefront Contents write (ledger publishing on)the publish step failsdelivery already done; only the public trust page lags
PAT loses Administration write laterrenewal logs WARN: re-invite ... GitHub returned 403 and does not spend the buyer's renewal allowance; it retries about once a daythe invitation still expires on its original schedule, so fix the token within a few days or re-invite by hand

Blast radius, honestly

Restricted Stripe key leaks (Checkout Sessions: Read). Can: read your checkout sessions (buyer names, emails, countries, amounts, and the GitHub usernames from the delivery field). That is real customer PII and worth protecting; rotation is one click in the Dashboard. Cannot: create charges or refunds, move or pay out money, touch products, prices, payment links, or any other resource. Every non-session call is refused. Compare Stripe's own words on the alternative: "If an unauthorized party obtains your secret API key, they can make unauthorized charges, access customer data, or disrupt your integration."

Fine-grained PAT leaks (Administration on the product repos). Can: fully administer those repositories. Invite anyone (including the attacker) at any permission level, remove buyers, change settings, delete the repo. Your product's content is at risk on GitHub; your local clone is the recovery path git already gives you. Cannot: see or touch any repository outside its named list, read your other code, act org-wide, or touch Stripe. If you enabled ledger publishing, add "push to the storefront repo" to the can-list. That's the whole widening.

Both leak at once. The attacker reads your sales history and controls the product repos. They still cannot move a cent.

Rotation drill, either key: revoke in the provider dashboard, mint a replacement with the same toggles, update the Actions secret. The next scheduled run heals itself. Flagged rows aside, there is no other state to fix.

Optional add-ons, same discipline

The instant-delivery upgrades each use their own separate token (never this PAT), and each is documented with its minimum: webhook relay (Contents R/W on the ops repo only, GitHub's floor for repository_dispatch) and heartbeat (Actions R/W on the ops repo only). The relay holds no Stripe key at all.


Permission names above were traced on 2026-07-19 against Stripe's restricted-key docs (keys/restricted-api-keys, keys-best-practices) and GitHub's REST reference (permissions for fine-grained PATs, collaborators). Stripe's Dashboard is the naming authority for its permission toggles; the one-run check above verifies your key against your account, which beats trusting any document, including this one.