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:
| Surface | Who hosts it | Who calls Sellub | Trust |
|---|---|---|---|
| Platform-hosted storefront | Sellub | The shopper’s browser | Public |
| Seller-branded storefront (own domain) | The seller | The shopper’s browser | Public |
| External landing page (3rd-party site) | A partner / NGO / brand | The visitor’s browser | Public |
| Embedded buy-button / iframe | Anyone | Cross-origin browser | Public + per-session |
| Server-to-server integration | A partner backend | A trusted server | Confidential |
| Webhook delivery | Sellub → consumer | Sellub | One-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, futureembed: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_…(orX-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:
- 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.
- Credential parse — reject malformed / missing keys with a generic error before doing any DB lookup. This makes timing-attacks pointless.
- Channel match — the
channelSlugin the request body must match the channel the key was issued for. No cross-channel calls. - Origin pin (publishable keys only) — the request
Originheader must be in the channel’sallowedOriginslist (wildcards likehttps://*.example.comare supported). Test-mode keys allowhttp://localhost:*. - Callback URL pin — every callback / redirect URL passed through
the API is checked against
allowedCallbackOrigins. Live mode requires HTTPS. - Permission check — the caller’s permission set must include the permission the endpoint declares. Publishable keys never get write permissions.
- 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:
- Admin GraphQL mutations —
sellubCreateApiKey,sellubRevokeApiKey,sellubSetAllowedOrigins,sellubSetAllowedCallbackOrigins. Each is gated onSuperAdmin∪UpdateChannel. - CLI —
node 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
| Surface | Credential(s) used | Where stored |
|---|---|---|
| Platform-hosted storefront | session cookie + channel-token | Sellub-managed |
| Seller-branded storefront | pk_live_* (origin = seller domain) | Build-time env |
| External landing page | pk_live_* (origin = partner domain) | Build-time env |
| Embedded buy-button | pk_live_* + per-session es_* | Static + minted on demand |
| Server-to-server | sk_live_* | Partner secret manager |
| Webhooks | whsec_* | Sellub + consumer secret manager |
Failure modes & rotations
- Publishable key leaked — revoke; redeploy frontend with the new
one. No money moves because
pkcannot 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
allowedIpsfield). - 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.