Skip to content

Dev tier credentials

Two stackrunner-issued bearers, one X.509 birth cert, one Firebase ID token, and (where Teller is in scope) one Teller credential. This is the credential surface a Dev-tier account owner works with day-to- day. It’s intentionally fatter than Free: Dev unlocks a second class of credential (“scoped credentials”) that gates the per-CSR cross-sign surface, plus a real X.509 birth cert for mTLS between your own services.

The credential surface at a glance

#CredentialStorageUsed forIssued by
1Master mint bearer (KV bearer)Workers KV mint-paid-state-prod at token:<sha256>POST /<handle>/mint (the legacy mint flow)bearer-claim-paid CF — claimed once via dashboard
2Scoped credential (permission-scope bearer)Projection D1 machine_permission_credentials.credential_hashPOST /v1/sign-leaf and POST /v1/cross-sign (forwarded as X-Mint-Credential)scripts/seed_signing_cert_bearer.py today (operator); dashboard-managed later
3Birth certCustomer-held P-256 key + leaf cert; we hold only the public key + fingerprintCustomer’s own mTLS between their services. Not used to talk to stackrunner.birth-cert-mint CF, one-time per customer
4Firebase ID tokenBrowser sessionDashboard SSO; gates bearer-claim-paid, birth-cert-mint, billing actionsFirebase Auth (Google / GitHub OAuth)
5Teller credential (when Teller in scope)Projection D1 machine_permission_credentials (same table as #2, different permission rows)POST teller.stackrunner.dev/<handle>/issue — projection-layer token issuancescripts/seed_teller_bearer.py (operator)

Credentials #2 and #5 share a table by design — both are permission-scoped, both ride as X-Mint-Credential / X-Teller-Credential, both hash on the way in. The distinction is which permission rows they bind to (signing-cert junction vs. teller projection junction).


1. The master mint bearer (KV bearer)

This is the closest analogue to the Free Pack bearer — opaque 32-byte secret, account-rooted, claimed once. The differences from Free:

  • It signs against your dedicated per-customer intermediate CA (dev-<handle>-intermediate, KMS-backed), not the shared Free intermediate.
  • The route shape is mint.stackrunner.dev/<ttl>/<handle>/mint, not mint.stackrunner.dev/<ttl>/v1/free/issue. Per-handle scope is in the path after the TTL prefix.
  • TTL allowlist is wider: {1h, 1d, 7d, 14d, 30d, 90d} — Dev tier adds 90d for the programmable-TTL case.
  • Quota: 5,000 leaves/month, batch up to 100 CSRs per call, all-or-nothing batch semantics.

Get it (one-time)

1. Sign in to the dashboard with Google or GitHub.
2. Click "Claim mint bearer".
3. Copy the plaintext from the modal — it appears once.
We store only the SHA-256 hash.

The dashboard calls the bearer-claim-paid Cloud Function, which mints 32 random bytes, hashes them, writes {handle, tier, created_at} to KV under token:<hash>, marks bearer_claimed_at on your Firestore customer record, and returns the plaintext to the browser only. A second claim attempt returns 409 already_claimed.

Use it

Terminal window
export STACKRUNNER_BEARER='<your-43-char-bearer-from-the-claim-modal>'
export STACKRUNNER_HANDLE=<your-handle>
openssl ecparam -name prime256v1 -genkey -noout -out leaf.key
openssl req -new -key leaf.key -out leaf.csr \
-subj "/CN=${STACKRUNNER_HANDLE}.leaf.example"
curl -fsS \
-H "Authorization: Bearer ${STACKRUNNER_BEARER}" \
-H "Content-Type: application/x-pem-file" \
--data-binary @leaf.csr \
https://mint.stackrunner.dev/7d/${STACKRUNNER_HANDLE}/mint \
| jq -r '.certs[0].cert_pem' > leaf.crt

Same TTL-in-URL contract as Free: URL subdomain wins, body ttl either matches it or is omitted; mismatch → 400 ttl_mismatch.

For multi-CSR batch minting (the lever raw PEM doesn’t expose), keep using the JSON envelope:

Terminal window
curl -fsS \
-H "Authorization: Bearer ${STACKRUNNER_BEARER}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg c1 "$(cat leaf1.csr)" --arg c2 "$(cat leaf2.csr)" \
'{version:"v1", csr_pems:[$c1,$c2]}')" \
https://mint.stackrunner.dev/7d/${STACKRUNNER_HANDLE}/mint

Up to PAID_BATCH_MAX = 100 CSRs per call, all-or-nothing.

Why not just use this for everything?

The master bearer is fine for an operator running mints by hand. It gets uncomfortable the moment you want to:

  • Hand a credential to a single CI pipeline that should only mint short-lived certs and never with a non-default Subject.
  • Rotate the credential a build farm uses without rotating the one a device fleet uses.
  • Revoke one team’s mint access without bricking everyone else.

That’s what scoped credentials are for.


2. Scoped credentials (the permission-scope bearer)

The reason Dev tier exists as a separate plane. A scoped credential is a bearer bound to a specific (machine_permission, signing_cert, mode) triple in the projection D1.

The permission surface

machine_permissions -- "permission objects"
└── machine_permission_signing_certs -- which signing certs may
issue under this permission,
in which modes
└── signing_certs -- the actual signing cert
(KMS-backed) the leaf is
chained to
└── machine_permission_credentials -- bearers bound to this
permission (sha256 hashed,
one row per credential)

A given account can have many machine_permissions. Each permission can be junctioned to one or more signing_certs, with modes being a comma-separated subset of {sign_leaf, cross_sign}. Each permission can have many active credentials — independent rotation.

When you call /v1/sign-leaf or /v1/cross-sign, the request body names a cert_id; the mint-crosssign Cloud Function:

  1. Hashes the X-Mint-Credential bearer.
  2. Looks up machine_permission_credentials.credential_hashmachine_permission_id.
  3. Checks machine_permission_signing_certs for a row with that (machine_permission_id, cert_id) and the requested mode in modes.
  4. Pulls the KMS key reference from signing_certs.kms_key_ref and signs.

Mismatch at any step → 403. The credential doesn’t own the cert; the permission does, and the credential is just one of N proofs of holding the permission.

Seed one (operator-side today)

Scoped-credential management is operator-driven via scripts/seed_signing_cert_bearer.py until the dashboard surface ships. Output is a fresh bearer written to .creds/<handle>-crosssign-bearer.txt (mode 0600, gitignored).

Terminal window
# Seed a credential for handle `apexrunner` with both modes:
scripts/seed_signing_cert_bearer.py apexrunner --modes=sign_leaf,cross_sign
# Output:
# .creds/apexrunner-crosssign-bearer.txt ← plaintext bearer
# D1 row in machine_permission_credentials (sha256 hashed)

The script is idempotent on the permission + junction (re-runs reuse the existing machine_permission and junction), but always writes a fresh credential row. To rotate, run it again and revoke the previous credential by revoked_at timestamp.

Use a scoped credential — /v1/sign-leaf (mode A)

Override the CSR’s Subject; the issued leaf carries <handle>.leaf.stackrunner.dev. Same profile as the master-bearer mint, just signed by a chosen cert_id.

Terminal window
export CRED=$(cat .creds/apexrunner-crosssign-bearer.txt)
CERT_ID=$(curl -fsS -H "Authorization: Bearer $(jq -r .api_token .creds/cloudflare.json)" \
"https://api.cloudflare.com/client/v4/accounts/$(jq -r .account_id .creds/cloudflare.json)/d1/database/abca2f42-972b-4b88-8b0a-13959c42f749/query" \
-d '{"sql":"SELECT cert_id FROM signing_certs WHERE label = ?","params":["apexrunner-intermediate"]}' \
| jq -r '.result[0].results[0].cert_id')
curl -fsS \
-H "Authorization: Bearer ${CRED}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg id "$CERT_ID" --arg csr "$(cat leaf.csr)" \
'{version:"v1", cert_id:$id, csr_pems:[$csr], ttl:"24h"}')" \
https://mint.stackrunner.dev/v1/sign-leaf \
| jq -r '.certs[0].cert_pem' > scoped-leaf.crt

Use a scoped credential — /v1/cross-sign (mode B)

Preserve the CSR’s Subject + SANs verbatim in the issued leaf. Useful when the device or service expects a specific identity and you just need it cross-signed under a stackrunner-managed cert.

Terminal window
curl -fsS \
-H "Authorization: Bearer ${CRED}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg id "$CERT_ID" --arg csr "$(cat leaf.csr)" \
'{version:"v1", cert_id:$id, csr_pems:[$csr], ttl:"7d"}')" \
https://mint.stackrunner.dev/v1/cross-sign \
| jq -r '.certs[0].cert_pem' > cross-signed-leaf.crt

The credential is only honoured for the modes its junction row allows. A mode=sign_leaf credential calling /v1/cross-sign gets a 403 mode_not_allowed.

Where scoped credentials are also used

  • EST enrollment (est.stackrunner.dev/.well-known/est/<batchId>/simpleenroll): same scoped credential rides as the Authorization: Bearer header; the EST worker forwards it as X-Mint-Credential to mint-crosssign. The EST <batchId> is the machine_permission_id.
  • Teller projection (teller.stackrunner.dev/<handle>/issue): separate Teller-scoped credential, same table, different permission rows.

3. Birth cert

The customer’s own mTLS leaf cert. Not a stackrunner-side credential at all — you hold the private key, we never see it. Use it for service-to-service mTLS between your own systems.

PropertyValue
KeyEC P-256, generated in the browser via WebCrypto.
Public key deliverySPKI DER, base64-encoded, sent to birth-cert-mint CF over the dashboard session.
SigningThe CF signs a 1-year cert against your dev-<handle>-intermediate.
LifecycleOne-time per customer — birth_cert_fingerprint on your Firestore record locks it. Renew = email support@.
Used by stackrunner?No. Bearer #1 / #2 authenticate you to us. The birth cert is yours, for your own mTLS.

(mTLS to stackrunner — i.e. presenting your birth cert at the mint.stackrunner.dev edge instead of a bearer — ships in Phase 1.5 once Cloudflare API Shield costs pencil. Currently bearer-only at the edge.)


4. Firebase ID token

Standard Google / GitHub OAuth flow. Browser-only, short-lived. Authenticates you to:

  • The dashboard SPA itself.
  • bearer-claim-paid (one-time, to claim bearer #1).
  • birth-cert-mint (one-time, to issue the birth cert).
  • Billing portal redirects (Stripe).

Not used to sign certs. Not equivalent to any of the bearers above.


5. Teller credential (when in scope)

Separate scoped-credential, lives in the same machine_permission_credentials table as #2 but bound to projection-layer permissions (issued in machine_permission_mint_batch_domains) rather than signing-cert junctions. Used by Teller’s projection-issue endpoint at teller.stackrunner.dev/<handle>/issue. Seeded today via scripts/seed_teller_bearer.py.

Out of scope of mint-side flows; documented here so the surface is complete.


Choosing the right credential

You want to…Use
Mint a cert by hand, account-wide quota, your own Subject auto-chosen#1 (master bearer)
Hand a credential to a CI pipeline that should mint only with a specific signing cert and only sign_leaf mode#2 scoped credential, modes=sign_leaf
Cross-sign a CSR that needs its Subject + SANs preserved#2 scoped credential, modes=cross_sign
Stand up an EST client (OpenSSL, libest, embedded TLS)#2 scoped credential as bearer to est.stackrunner.dev
Authenticate your services to each other (your own mTLS)#3 birth cert
Sign into the dashboard#4 Firebase ID
Issue Teller projections (JWT/JWE/COSE/SPIFFE)#5 Teller credential

Rotation, revocation, recovery

  • Master bearer (#1): not self-serve today. Email support@; we delete the KV record and reissue. Cuts off in-flight callers using the old plaintext — coordinate.
  • Scoped credentials (#2, #5): rotate by re-running the seed script; revoke by setting revoked_at on the machine_permission_credentials row. Other credentials on the same permission keep working — that’s the whole point of having them scoped.
  • Birth cert (#3): customer-managed key; if compromised, rotate your own services first, then revoke the cert via your CRL (revoke-paid CF) and email support@ to clear the birth_cert_fingerprint flag so you can mint a new one.
  • Firebase (#4): standard Google/GitHub session controls. Sign out to invalidate.

See also