ConceptsSecurity

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 (429 with retry-after) rather than turned into unbounded lookups. A lookup infrastructure failure is 503 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/credentials requires the manage:own_credentials scope, and any requested scope the issuing key does not hold is a 403 scope_escalation naming 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 an fdk_ key. It is owner-only, the tenant is derived from the session, and the scopes granted depend on store status: a live store gets the full merchant scope set, a draft store gets a deliberately narrow pre-live build set (store, catalog, media, domains — no money scopes, and notably not manage:own_credentials), a suspended or 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.

CredentialHeaderLives where
Merchant fdk_ keyAuthorization: Bearer fdk_...server-side, per tenant
Storefront tokenx-storefront-tokenserver-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 http or https (invalid_scheme).
  • localhost, loopback, the cloud metadata address 169.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 any CRITICAL finding is a 422 ruo_claims_violation telling the merchant to contact the operator. There is no merchant-facing bypass.
  • HIGH findings return as warnings, not blocks.

Threats and mitigations

ThreatMitigation
Cross-tenant data access via a forged path/body tenantTenant read from the credential only; no default tenant
Stolen credential replayedKeys hashed at rest; revocation checked on every verify; per-key rate limit before lookup
Privilege escalation by minting a stronger keySubset-gating; 403 scope_escalation; first key requires an owner identity session
Storefront token exfiltrated from a browserServer-side only, no browser SDK, no CORS; token redacted from error reports
SSRF / cloud-metadata fetch via a merchant webhook URLWebhook targets validated at create and dispatch; loopback, private-range, and metadata addresses rejected
Non-compliant RUO claims going liveManifest guardrail: hard 422 for merchants, 503 fail-closed on scanner error
Uncaught error leaking internals to a clientSingle error boundary: the client sees { error: { code, message, correlationId } }