SellubDevelopersSecurity model

Sellub Security Model

This document describes the credential model that protects every Sellub integration surface — from a fully-hosted platform storefront down to a single donation button embedded in someone else’s website.

The design follows the same pattern Stripe / Paystack use for their public APIs: a small set of well-typed credentials, each with a clearly-scoped trust level, and an enforcement layer that decides what each one is allowed to do.


Why this exists

Sellub serves several very different consumer types:

SurfaceWho hosts itWho calls SellubTrust
Platform-hosted storefrontSellubThe shopper’s browserPublic
Seller-branded storefront (own domain)The sellerThe shopper’s browserPublic
External landing page (3rd-party site)A partner / NGO / brandThe visitor’s browserPublic
Embedded buy-button / iframeAnyoneCross-origin browserPublic + per-session
Server-to-server integrationA partner backendA trusted serverConfidential
Webhook deliverySellub → consumerSellubOne-way signed

A single shared API key cannot safely cover all six. Public surfaces leak their credentials by design (every visitor’s DevTools sees them), while confidential surfaces need full read/write power. The model below gives each surface exactly what it needs and nothing more.


The five credential types

1. Channel access token (internal, already used today)

The opaque channel-token Vendure assigns to every channel. Used by storefronts and dashboards that already have a session; controls which catalog, prices, and seller an authenticated request applies to. Not itself an authentication credential — it identifies what, not who.

2. Publishable key — pk_live_* / pk_test_*

Browser-safe. Issued per channel, bound to that channel, and pinned to an allow-list of HTTPS origins.

  • Sent on every request as X-Sellub-Publishable-Key.
  • Grants only public, payment-initiation-style operations (external-payments:initialize, external-payments:verify, future embed:create-session).
  • Cannot read orders, cannot create products, cannot move money to a different account.
  • Safe to ship in client bundles, env-var-injected at build time, or paste into a CMS field.

3. Secret key — sk_live_* / sk_test_*

Server-side only. Same per-channel scoping, but no origin restriction (servers don’t have origins). Carries write permissions appropriate for that channel: create draft orders, refund, fulfill, mint embed sessions, read full transaction history.

  • Sent as Authorization: Bearer sk_live_… (or X-Sellub-Secret-Key).
  • Never exposed to a browser. If one ever reaches a frontend bundle, rotate it immediately.
  • Stored hashed at rest (the raw key is shown once, at creation time).

4. Embed session JWT — es_*

Short-lived (≈15 min) signed token minted by a partner’s server using its secret key. Authorises a single embedded checkout / cart / buy-button session in a specific browser:

  • Bound to one channel, one cart/order, optionally one customer.
  • Can be safely posted into a third-party iframe without exposing the underlying secret key.
  • Cannot be re-used after expiry; cannot escalate to admin operations.

This is the Stripe-Connect-style “I want to host the checkout but the seller controls the funds” pattern.

5. Webhook signing secret — whsec_*

One per channel + endpoint. Sellub signs every outgoing webhook with HMAC-SHA256 over the raw body and a timestamp:

X-Sellub-Signature: t=<unix>,v1=<hex(hmac_sha256(secret, t + "." + body))>

Consumers verify the signature and reject any payload older than 5 min. Replay protection comes from the timestamp + idempotency key. If the secret leaks, rotating it invalidates every in-flight forgery without requiring code changes elsewhere.


Enforcement layer

Every public endpoint runs the same gauntlet:

  1. Rate limit (per IP, per channel) — token-bucket; the channel limit is the harder cap. Defaults: 10 req/min/IP, 100 req/min/channel for payment initialisation.
  2. Credential parse — reject malformed / missing keys with a generic error before doing any DB lookup. This makes timing-attacks pointless.
  3. Channel match — the channelSlug in the request body must match the channel the key was issued for. No cross-channel calls.
  4. Origin pin (publishable keys only) — the request Origin header must be in the channel’s allowedOrigins list (wildcards like https://*.example.com are supported). Test-mode keys allow http://localhost:*.
  5. Callback URL pin — every callback / redirect URL passed through the API is checked against allowedCallbackOrigins. Live mode requires HTTPS.
  6. Permission check — the caller’s permission set must include the permission the endpoint declares. Publishable keys never get write permissions.
  7. Audit log — keyId, channel, IP, origin, route, status code. Mask the key body in every log line.

The same gauntlet runs on the server-issued callback that Paystack hits: we re-derive the channel from the stored transaction, look up its allowedCallbackOrigins, and only redirect there. If the lookup fails we fall back to a static “thank you” page rather than honoring an attacker-supplied URL.


Storage

Keys are stored on the channel itself (Channel.customFields):

  • sellubApiKeys — JSON array of { id, prefix, hash, createdAt, lastUsedAt, revokedAt, allowedOrigins?, permissions[] }.
  • sellubAllowedOrigins — channel-wide default origin allow-list (a key may narrow it but never widen it).
  • sellubAllowedCallbackOrigins — HTTPS callback allow-list.
  • sellubEmbedSessionSecret — readonly, auto-rotated, used to sign embed JWTs.

Hashes are SHA-256 of the raw key. Comparison is constant-time. The raw key is never persisted.


Issuing keys

Two paths:

  1. Admin GraphQL mutationssellubCreateApiKey, sellubRevokeApiKey, sellubSetAllowedOrigins, sellubSetAllowedCallbackOrigins. Each is gated on SuperAdminUpdateChannel.
  2. CLInode dist/mint-api-key.js --channel <slug> --name <label> [--prefix pk_live | pk_test | sk_live | sk_test] [--origin https://example.com] [--callback https://example.com/return]. Prints the raw key once; the operator is responsible for storing it securely.

The dashboard surfaces a UI on top of (1).


Mapping to the six surfaces

SurfaceCredential(s) usedWhere stored
Platform-hosted storefrontsession cookie + channel-tokenSellub-managed
Seller-branded storefrontpk_live_* (origin = seller domain)Build-time env
External landing pagepk_live_* (origin = partner domain)Build-time env
Embedded buy-buttonpk_live_* + per-session es_*Static + minted on demand
Server-to-serversk_live_*Partner secret manager
Webhookswhsec_*Sellub + consumer secret manager

Failure modes & rotations

  • Publishable key leaked — revoke; redeploy frontend with the new one. No money moves because pk cannot read or refund.
  • Secret key leaked — revoke immediately; rotate every dependent webhook secret. Audit log shows everything that key ever did.
  • Embed JWT leaked — useless after 15 min; the cart it authorises is scoped to one customer.
  • Webhook secret leaked — rotate; replay window is 5 min.
  • Test key in production — server rejects (the env tag is part of the prefix and must match NODE_ENV).

Every revocation is immediate — no caches to warm — because verification hits the channel record directly.


What’s not in the model (yet)

  • mTLS for server-to-server (planned for high-volume integrations).
  • Per-key IP allow-listing (publishable keys only have origin pinning; secret keys can opt in via a future allowedIps field).
  • Hardware-backed signing (HSM / KMS) for whsec_* — current implementation uses application-level secrets stored in the channel record. A future migration will move signing keys behind KMS without changing the wire format.

These are not blockers for current launch traffic but should be revisited once a tenant exceeds ~1k req/min sustained.