Security
RUO Pro’s security posture is a set of properties enforced where they cannot be forgotten. This page is the trust model as it applies to you — what each credential proves, what a scope can and cannot do, how tokens are handled, and the specific threats each control closes.
Credential-carries-ownership
Tenant-facing auth follows one pattern everywhere: API keys are resource-bound. A key’s scope says “some tenant can do X”; the key itself says which tenant. Ownership is always read from the credential, never from a path or body parameter the caller supplied. This is the enforcement half of fail-closed tenancy — there is no default tenant to fall back to.
The merchant fdk_ credential is the concrete case:
- Stored hashed. Only a hash of the key is retained; the plaintext key is returned exactly once at issuance and never again. An unknown or revoked key resolves to nothing.
- Bound to a tenant. Every tenant-facing route resolves the tenant from the verified credential, so one tenant’s key can never read or write another tenant’s data.
- Rate-limited before the lookup. An online key-guessing attempt is
throttled (
429withretry-after) rather than turned into unbounded lookups. A lookup infrastructure failure is503 auth_unavailable, never a silent pass.
The scope model
Scopes are a closed allowlist. A request for any string not on the list is
rejected, and a credential must carry at least one — so a credential can never
carry a scope the platform does not define. Scopes are coarse and
ownership-shaped (read:own_orders, manage:own_catalog, manage:own_domains,
manage:own_credentials, and so on).
Two rules keep the scope system from becoming an escalation vector:
- Subset-gating. A key can only mint another key whose scopes are a
subset of its own.
POST /v1/my/credentialsrequires themanage:own_credentialsscope, and any requested scope the issuing key does not hold is a403 scope_escalationnaming the excess scopes — a merchant key can never manufacture a more powerful key. - First key by identity session, not by another key. The bootstrap
problem — how do you get your first credential — is solved by
POST /v1/my/credentials/initial, which authenticates a signed-in human session, not anfdk_key. It is owner-only, the tenant is derived from the session, and the scopes granted depend on store status: alivestore gets the full merchant scope set, adraftstore gets a deliberately narrow pre-live build set (store, catalog, media, domains — no money scopes, and notably notmanage:own_credentials), asuspendedor missing store mints nothing.
Because the initial-credential broker derives the tenant from the session and refuses any non-owner, a caller cannot mint a key for a tenant they do not own.
Token handling — the storefront token stays server-side
There are three distinct credential families, and the important security property is where each one is allowed to live.
| Credential | Header | Lives where |
|---|---|---|
Merchant fdk_ key | Authorization: Bearer fdk_... | server-side, per tenant |
| Storefront token | x-storefront-token | server-side only |
The storefront token is the credential a headless frontend uses to reach
public commerce and checkout routes. It is meant for a server-side runtime
only; the SDK’s storefront client sends it as x-storefront-token from a
server, never from a browser. There is deliberately no browser-side SDK and
no CORS opening — a token in browser JavaScript is a token you have
published. The token is also redacted from error reports, so it cannot leak
that way.
Never ship the storefront token to a browser, a mobile app bundle, or any client you do not control. Keep it on your server and proxy storefront calls through your own backend.
Webhook targets must be publicly reachable
Merchant webhook URLs are attacker-influenced, so the platform validates them at subscription-create time and again immediately before every dispatch, retry, and redrive:
- The scheme must be
httporhttps(invalid_scheme). localhost, loopback, the cloud metadata address169.254.169.254, and any private or special-range IP are rejected (private_host).
Point your webhook subscription at a publicly reachable HTTPS endpoint you control. For verifying that a delivery genuinely came from RUO Pro, see Webhooks.
The RUO-claims guardrail
The RUO vertical sells research-use-only compounds, so store copy must not
make disease, structure-function, dosing, or prescription-drug claims. Every
manifest publish is scanned for matching terms, classified by severity
(CRITICAL for disease/dosing/prescription-drug-name, HIGH for
structure-function):
- Fail-closed on scanner error. If the scan itself fails, the publish is
503 scan_unavailable— “this manifest was NOT verified” — never a silent pass. - Merchants get a hard block, no override. A merchant publish
(
PUT /v1/my/store/manifest) with anyCRITICALfinding is a422 ruo_claims_violationtelling the merchant to contact the operator. There is no merchant-facing bypass. HIGHfindings return as warnings, not blocks.
Threats and mitigations
| Threat | Mitigation |
|---|---|
| Cross-tenant data access via a forged path/body tenant | Tenant read from the credential only; no default tenant |
| Stolen credential replayed | Keys hashed at rest; revocation checked on every verify; per-key rate limit before lookup |
| Privilege escalation by minting a stronger key | Subset-gating; 403 scope_escalation; first key requires an owner identity session |
| Storefront token exfiltrated from a browser | Server-side only, no browser SDK, no CORS; token redacted from error reports |
| SSRF / cloud-metadata fetch via a merchant webhook URL | Webhook targets validated at create and dispatch; loopback, private-range, and metadata addresses rejected |
| Non-compliant RUO claims going live | Manifest guardrail: hard 422 for merchants, 503 fail-closed on scanner error |
| Uncaught error leaking internals to a client | Single error boundary: the client sees { error: { code, message, correlationId } } |