Skip to content
downpipes docs

Owner-minted pull credentials for vendor support

A pull credential is a short, scoped, read-only lease that an Owner mints so vendor support can fetch the support bundle during a live ticket. It is the one escalation in a support model that otherwise holds nothing on the vendor side, and it is the last resort, not the default. The default flow stays in your hands: any signed-in role downloads the support bundle from the console and attaches it to the ticket. Mint the pull credential only when that is not working, because the download or the ticket upload keeps failing, or because support needs to pull fresh evidence repeatedly while the ticket is open, and support agrees a remote pull is the right step.

This page is for the self-hoster who owns the engine and decides when to open and close that lease. The single fact to keep in front of you is that this is a per-grant lease, not a vendor-held standing credential. It is minted once, shown once, time-boxed, read-only, and revocable at any moment, and the vendor never holds anything that outlasts your decision to allow it. This page documents the credential under the diagnostics scope; the separate audit-feed scope, for your own SIEM collector, has a different lifetime and is covered with the audit log.

What the credential is

A pull credential is a client and secret pair. The client id is a non-secret lookup label; the secret is the bearer that authorises a pull. The engine generates both, returns the secret exactly once at grant time, and stores only its SHA-384 hash, never the secret itself (mintIngestCredential, engine/src/admin/support-ingest.ts). Because only the hash is kept, neither the engine nor the console can ever hand you the live secret again after the one-time reveal.

That has a direct consequence for rotation. There is no edit or reissue of the same secret: rotation means revoke and re-mint. Only one credential is active per scope, so minting a new one replaces the old grant and kills the old secret immediately (mintIngestCredential replaces the stored grant; the console confirms this at the button). A collector or support engineer still holding the old secret loses access the moment you re-mint.

The secret is shown exactly once, at mint time. The console displays it behind a conceal-and-reveal field that copies without revealing, and never stores it; navigating away discards it. There is no way to recover it afterwards, so capture it when you mint it (oneTimeCredentialReveal, console/src/screens/settings/support.ts).

The diagnostics scope: read-only, outside the admin API

The diagnostics scope lets vendor support pull the signed bundle, and the sealed bundle where a vendor key is configured, from GET /support/diagnostics. That route lives outside /admin, so support presents only the bearer credential: no console seat, no Cloudflare Access seat, and no admin token (handleSupportPull, engine/src/admin/support-ingest.ts). The credential grants exactly that one read-only feed. It carries no admin route, no restore, no configuration write, and no path to your data.

The lease is short by design. The default time to live is 72 hours and the cap is 7 days, and the engine server-clamps any requested value to a minimum of 60 seconds (INGEST_TTL_CAPS_SECONDS and the clamp in mintIngestCredential, engine/src/admin/support-ingest.ts). So even an un-revoked credential ages out on the engine’s own clock, well inside the life of a normal ticket.

Property Diagnostics credential
Surface GET /support/diagnostics, outside /admin
Access Read-only; the signed and, where configured, sealed bundle only
Seats required None: no console seat, no Cloudflare Access seat, no admin token
Default TTL 72 hours
Maximum TTL 7 days, clamped to a 60-second minimum
Active grants One per scope; re-minting replaces and immediately kills the old secret

If Cloudflare Access fronts the hostname

/support/diagnostics rides the console’s public hostname in the shipped topology, so a Cloudflare Access application in front of that hostname turns support’s pull away at the edge, with a redirect to your team’s login page, whatever credential support carries. The console warns you at mint time when the engine detects the perimeter (the accessPerimeter flag on GET /admin/support, engine/src/admin/router-status.ts), so the wall is named before you hand over a credential that cannot reach the route.

The remedies are the same as for the audit feed, applied to this path for the life of the ticket: a path-scoped Access application over /support/diagnostics with a Service Auth policy and an Access service token you hand to support alongside the pull credential, or a narrow Bypass on that path, or the split topology. The trade-offs, the exact headers, and the one configuration to avoid are described once, with the audit feed (the Access perimeter section). Whichever you choose, the pull credential remains the engine-side gate, and revoking it ends support’s access regardless of what the perimeter allows through.

Minting and revoking, step by step

  1. An Owner mints the credential

    Minting is Owner-only, and the gate is enforced in depth. The admin router checks the Owner role before it forwards the request and records a refused attempt as an audited deny, the same way a refused role change is recorded (POST /support/credentials, engine/src/admin/router-status.ts). The scheduler Durable Object then re-resolves the Owner role from its own tables at the storage commit point, so a non-owner request is refused even if it somehow reached the store (POST /ingest-credential/set, engine/src/sched/scheduler-do-routing.ts).

  2. The Owner proves they are present

    A mint needs a fresh step-up re-authentication, always, whatever else is configured. A session cookie alone is not enough: the console asks for a fresh passkey assertion and retries the mint with the single-use token it gets back, so the route is out of reach of a stale ambient session. This is not the dual-control gate below and does not substitute for it. Step-up is always on and needs only you; dual control is opt-in and needs a second Owner to exist. The route was added to the step-up set on 2026-08-04 because minting a bearer carries the same custody weight as granting a role, and granting a role was already gated (step-up re-authentication).

  3. A second Owner approves, when dual control is on

    Minting opens a vendor-readable pull surface, so it is subject to opt-in dual control. When dual control is enabled, the first mint records a pending approval and returns without minting; a second Owner approves the decision, which binds to the scope and TTL only and never to the secret; then the original Owner re-submits and the mint runs exactly once (the support-credential-mint gate call in engine/src/admin/router-status.ts, calling ownerActionGate defined in engine/src/admin/router-core.ts). The one-time secret cannot be pre-recorded, which is why it is the decision that is approved rather than the secret.

  4. Support pulls the bundle

    Support presents the credential as a single bearer of the form client-id then a dot then secret. The engine verifies it with a constant-time comparison against the stored hash, and it checks server-side expiry first, so a stale credential is refused before the hash is even compared (checkIngestCredential, engine/src/admin/support-ingest.ts). A failed presentation is a plain 401 with no detail. Every successful pull is recorded on the grant.

  5. The Owner revokes it

    Revocation is immediate and Owner-only, with the same in-depth gate as minting: the router checks the role and the Durable Object re-resolves it at the commit point (POST /support/credentials/delete and POST /ingest-credential/clear). Confirm it and support stops working at once. To rotate instead, re-mint, which issues a fresh secret and kills the old one straight away.

What you can see, and what you can never see

The console shows the customer-facing view of a grant, and that view is built from a redacted record that has no secret in it by construction. You see the client id, the scope, who granted it, the expiry, an expired flag, and the pull trail. You never see the secret or its hash (redactGrant, engine/src/admin/support-ingest.ts; supportGrantPresentation, console/src/screens/settings/support.ts).

The pull trail is the usage record. Every successful pull is recorded on the grant, and the engine retains the most recent 50 pull timestamps, rolling older entries over (POST /ingest-credential/record-pull slices to the last 50, engine/src/sched/scheduler-do-routing.ts). So the console reports recent usage honestly: at the cap it says the most recent 50 are retained and older entries roll over, rather than a total it cannot know. This is how you confirm whether, and when, a credential you minted was actually used.

The grantedBy field records the granting Owner’s verified email, for attribution. It is null for the bare admin-token break-glass path, which has no email and is not attributable to a person, and the console says so plainly when it renders the grant (supportGrantPresentation shows the bare-token path is not attributable, console/src/screens/settings/support.ts).

A diagnostics pull is read-only and tamper-evident in transit, because the bundle it returns is signed by your engine and sealed when a vendor key is configured. It is not a write path and cannot reach your data, and the credential is not a claim of being unforgeable; it is a short lease verified against a stored hash with constant-time comparison.

Why this is consistent with no-custody

A lease you create, scope to read-only, time-box, and revoke does not contradict the claim that the vendor holds nothing. The vendor never holds it as a standing capability. It exists only while you allow it, grants only one read-only feed, and the engine refuses it the moment it expires or you revoke it (checkIngestCredential and handleSupportPull, engine/src/admin/support-ingest.ts). The control sits with you at every step, which is the property no-custody is about. The secret carries about 192 bits of entropy and the comparison leaks no timing, so brute force is not a realistic path against the surface.

Deeper detail: the bearer form, verification, and the three scopes

The bearer is one opaque field. The credential is presented as Authorization: Bearer <clientId>.<secret>, because one opaque field fits every collector’s configuration form and the vendor’s support tooling alike (handleSupportPull, engine/src/admin/support-ingest.ts). The engine splits on the first dot, checks the client id matches, confirms the secret carries the expected prefix, then runs the constant-time hash comparison.

Verification checks expiry first. checkIngestCredential rejects an expired grant before it touches the hash, validates the stored hash is a well-formed 96-character hex SHA-384, and compares in constant time (engine/src/admin/support-ingest.ts). It never logs or echoes the presented value.

Three scopes, three lifetimes. The platform issues a credential per scope. The diagnostics scope on this page is short-lived for support pulls. A separate audit-feed scope lets your own SIEM collector poll the hash-chained audit events on a long-lived schedule, defaulting to 90 days and capped at 365, cursored by sequence number for checkpointing. A third, metrics scope lets a Prometheus-compatible scraper pull /metrics on the longest-lived schedule of the three, defaulting to 365 days and capped at 400. The audit feed and the metrics feed are both customer-operated, not a vendor path; the audit feed carries operator identity and is described under the audit log, and the metrics scope is described under Prometheus metrics.

Where this fits

This page owns the diagnostics-credential lifecycle. For the file the credential fetches, field by field, and its four honest states, see the support bundle. For the support model end to end and the precise meaning of “no vendor access,” see getting support without giving us access. For the separate audit-feed scope and the hash-chained events it carries, see the audit log.

Last updated .