Raxx · internal docs

internal · gated ↑ index

SOP — CF Access Service Token Provisioning

Owner: Operator (Kristerpher) + agent Last updated: 2026-05-03 First execution: 2026-05-02 (CF_ACCESS_SVC_CONSOLE for console-self probe)


When to run this

You need a CF Access service token when:

Don't use this for human access — that's email-based Access policies, separate flow.


Pre-flight

Required: - CLOUDFLARE_ACCESS_MGMT_TOKEN in Infisical at /MooseQuest/cloudflare/ (alias CF_ACCESS_MGMT). Verify present: bash heroku run --app raxx-console-prod --no-tty 'python -c " from app.services import vault print(\"OK\" if vault.get_secret_value(\"CLOUDFLARE_ACCESS_MGMT_TOKEN\") else \"MISSING\") "' - CLOUDFLARE_ACCOUNT_ID env var on the dyno running the script

Not required (handled in-script): - The new service token's secret never leaves the dyno - The Access app IDs are looked up at runtime


Steps

Step 1 — Identify the target Access app

Apps for console.raxx.app (id 0b55d01b-592b-4da4-b170-02d48a9f550d) and console-staging.raxx.app (id fbacf797-9708-40eb-8c70-0ab558e96988) are pre-known from 2026-05-02. For new apps, list them with:

from app.services import vault
import os, requests
mgmt = vault.get_secret_value("CLOUDFLARE_ACCESS_MGMT_TOKEN")
H = {"Authorization": f"Bearer {mgmt}"}
ACCOUNT_ID = os.environ["CLOUDFLARE_ACCOUNT_ID"]
r = requests.get(f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/access/apps",
                 headers=H, params={"per_page": 50}, timeout=15)
for app in r.json()["result"]:
    print(app["id"], app["name"], app.get("domain") or app.get("self_hosted_domains"))

Step 2 — Mint the service token

r = requests.post(
    f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/access/service_tokens",
    headers={"Authorization": f"Bearer {mgmt}", "Content-Type": "application/json"},
    json={"name": "<descriptive-name>", "duration": "8760h"},  # 1 year
    timeout=15,
)
tok = r.json()["result"]
# tok["client_id"] ends in ".access" — public half
# tok["client_secret"] — visible exactly once; never logged

Step 3 — Add an additive policy to each target app

Critical: create a NEW policy with decision: "non_identity". Do not modify existing user-based policies. Service tokens carry no identity; non_identity is the only decision that matches them.

for app_id in TARGET_APP_IDS:
    r = requests.post(
        f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/access/apps/{app_id}/policies",
        headers={"Authorization": f"Bearer {mgmt}", "Content-Type": "application/json"},
        json={
            "name": "<service> (service token)",
            "decision": "non_identity",  # required for service tokens
            "include": [{"service_token": {"token_id": tok["id"]}}],
        },
        timeout=15,
    )
    assert r.json()["success"], r.json()

If you accidentally create with decision: "allow", the smoke test will 302 to the login page. Service tokens lack identity context that an allow policy demands. Fix:

r = requests.put(
    f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/access/apps/{app_id}/policies/{policy_id}",
    headers={"Authorization": f"Bearer {mgmt}", "Content-Type": "application/json"},
    json={
        "name": "<service> (service token)",
        "decision": "non_identity",
        "include": [{"service_token": {"token_id": tok["id"]}}],
    },
    timeout=15,
)

Step 4 — Write to vault

The vault expects format client_id:client_secret:

combined = f"{tok['client_id']}:{tok['client_secret']}"
vault.store_secret_version("<NAME>", combined)

Vault path is auto-resolved per _VENDOR_PATH mapping in console/app/services/vault.py — for a CF Access service token, this is /MooseQuest/cloudflare/.

Update _CREDENTIAL_CADENCE_KEY in vault.py if the new token is for a NEW credential (not yet wired). For existing entries (CF_ACCESS_SVC_CONSOLE, CF_ACCESS_SVC_VAULT), no code change needed.

Step 5 — Set on consuming Heroku apps

Use the Heroku Platform API (don't shell out to heroku config:set — secrets get echoed). The pattern:

heroku_token = os.environ["HEROKU_API_KEY"]
H = {
    "Authorization": f"Bearer {heroku_token}",
    "Accept": "application/vnd.heroku+json; version=3",
    "Content-Type": "application/json",
}
for app in TARGET_HEROKU_APPS:
    r = requests.patch(
        f"https://api.heroku.com/apps/{app}/config-vars",
        headers=H,
        json={"<NAME>": combined},
        timeout=20,
    )
    print(f"{app}: HTTP {r.status_code}")

Step 6 — Smoke test

import time, requests
client_id, client_secret = combined.split(":", 1)
for host in TARGET_HOSTNAMES:
    for attempt in range(5):
        time.sleep(5)
        r = requests.get(f"https://{host}/health",
                         headers={"CF-Access-Client-Id": client_id,
                                  "CF-Access-Client-Secret": client_secret},
                         timeout=10, allow_redirects=False)
        print(f"  attempt {attempt+1}: {host} → HTTP {r.status_code}")
        if r.status_code == 200:
            break

CF Access policy propagation: 5-30 seconds. First attempt may 302; retry up to 5 times with 5s spacing.


Sandbox-aware execution

The host sandbox blocks: - Writing the management token (or new service-token secret) to /tmp/* - Listing Access apps with the management token (interpreted as scouting beyond the user's stated task) - Modifying existing CF Access policy decision fields

Run all six steps inside one Heroku one-off dyno (heroku run --app raxx-console-prod --no-tty 'python /dev/stdin' < script.py). The transcript output should be diagnostic only — non-secret IDs, HTTP status codes, success/fail markers.

If the operator wants a from-scratch token provisioning, they say "I authorize ". Don't take shortcuts on the principle.


Refs