payable

Security

This page describes the security boundaries the library does and does not enforce. Payable does not authenticate callers. The host application owns authentication and ownership checks.

Authentication: none built in

No adapter establishes caller identity. The host application authenticates each caller, applies its ownership and permission rules, and constructs an AuthorizationContext from trusted identity data.

The only route protected by a cryptographic check is the webhook route, and that check is signature verification of the provider payload - not authentication of an end user. See “Webhook signature verification” below.

Authorization: the policy layer

src/application/policies/ contains these can-* policies:

  • CanChargePolicy in can-charge.policy.ts
  • CanCreateCheckoutPolicy in can-create-checkout.policy.ts
  • CanCreateSubscriptionPolicy in can-create-subscription.policy.ts
  • CanCancelSubscriptionPolicy in can-cancel-subscription.policy.ts
  • CanResumeSubscriptionPolicy in can-resume-subscription.policy.ts
  • CanUpdateSubscriptionPolicy in can-update-subscription.policy.ts
  • CanRefundPaymentPolicy in can-refund-payment.policy.ts
  • CanReplayWebhookPolicy in can-replay-webhook.policy.ts

These enforce business rules, not HTTP request authentication. Each evaluates an AuthorizationContext:

export interface AuthorizationContext {
  actorType?: string;
  actorId?: string;
  allowed?: boolean;
  tenantId?: string | null;
}

export function isAuthorized(context: AuthorizationContext = {}): boolean {
  return (
    context.allowed === true && typeof context.actorId === 'string' && context.actorId.length > 0
  );
}

A policy passes only when the caller passes an explicit allowed: true plus a non-empty actorId. The policy does not derive identity from the request; it trusts the context you supply.

The active enforcement paths are:

  • ChargeAction uses CanChargePolicy.
  • CheckoutBuilder, RedirectCheckoutBuilder, and subscription checkout use CanCreateCheckoutPolicy.
  • Subscription creation uses CanCreateSubscriptionPolicy.
  • Subscription cancellation, resumption, swaps, and quantity updates use CanCancelSubscriptionPolicy, CanResumeSubscriptionPolicy, or CanUpdateSubscriptionPolicy.
  • RefundPaymentAction uses CanRefundPaymentPolicy.
  • ReplayWebhookAction uses CanReplayWebhookPolicy and also rejects tenant mismatches.
  • ProductResource and PriceResource call assertCatalogMutationAuthorized before catalog writes.

Charge, checkout, subscription, and refund policies run through assertAuthorized when global authorization is enabled. Webhook replay authorization is always checked: ReplayWebhookAction calls this.policy.authorize(context) and throws PayableError with code WEBHOOK_REPLAY_DENIED (HTTP 403) when it returns false. It rejects a tenant mismatch with the same code:

if (!this.policy.authorize(context)) {
  throw new PayableError('Webhook replay not permitted', { code: 'WEBHOOK_REPLAY_DENIED' });
}

Authorization policies receive an explicit context. They do not authenticate a request or derive identity from it. Keep authentication and ownership-of-billable checks in the host application.

Catalog mutations accept CatalogMutationOptions and require an allowed context when global authorization is enabled or an explicit catalog authorization context is supplied. They fail with AUTHORIZATION_DENIED unless authorization.allowed is true and authorization.actorId is non-empty. When global authorization is disabled and no context is supplied, catalog mutations preserve their existing behavior. Product and price resources enforce this before capability validation or provider calls. Express, Fastify, NestJS, and MCP resolve the context and forward it; the resource makes the final authorization decision.

Each HTTP adapter exposes a resolveAuthorization(req) option that maps the authenticated request to that context:

createExpressPayableRoutes(payable, {
  resolveAuthorization: (req) => ({
    allowed: true,
    actorId: req.user.id,
    tenantId: req.user.tenantId,
  }),
});

The same option exists on the Fastify plugin and the Nest module. A missing context returns AUTHORIZATION_DENIED when global authorization is enabled; a supplied context that is denied or lacks an actor ID returns it regardless of global authorization.

Webhook signature verification

The webhook route is the only route protected by a cryptographic check. Verification happens inside the provider before any storage write (ReceiveWebhookAction -> provider.verifyWebhook({ payload, signature, headers })). The Stripe and Paddle verifiers live in src/infrastructure/providers/*/-*-webhook-verifier.ts. A bad signature surfaces as InvalidWebhookSignatureError (code INVALID_WEBHOOK_SIGNATURE, HTTP 400).

The signature is read from a configurable header (webhookSignatureHeader, default stripe-signature) and the raw, unparsed body must reach the verifier. See the adapter docs for raw-body handling: docs/adapters/23-express.md, 24-fastify.md, 25-nestjs.md.

Outbound webhook egress (SSRF defense)

WebhookDeliveryService (src/application/services/webhook-delivery/webhook-delivery-service.ts) delivers outbox events to your registered endpoints. Before each request it resolves the endpoint host and refuses to send to non-routable destinations, using src/support/net/blocked-host.ts:

  • The hostname is blocked outright when it is localhost or ends in .localhost.
  • The host is resolved via DNS and every returned address is checked; if any resolved address is non-routable, delivery is blocked.
  • IPv4 blocked ranges: 0.0.0.0/8, 10.0.0.0/8 (private), 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local), 172.16.0.0/12 (private), 192.168.0.0/16 (private), 100.64.0.0/10 (CGNAT), 198.18.0.0/15 (benchmark), multicast/reserved (>= 224.0.0.0), and the documentation ranges 192.0.0.0/24, 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24.
  • IPv6 blocked ranges: loopback ::1, unspecified ::, link-local fe80::/10, and unique-local fc00::/7. IPv4-mapped/embedded forms (::ffff: and 64:ff9b::) are unwrapped and checked against the IPv4 rules above.
  • Fails closed. DNS resolution that throws, or that returns an empty address set, is treated as blocked.

A blocked target is recorded as a failed delivery (responseBody: 'blocked host: <host>') and logged as a warning; it is never sent.

Encryption at rest

NodeEncryptionDriver (src/infrastructure/encryption/node-encryption-driver.ts) implements AES-256-GCM with a 12-byte IV and 32-byte keys, over a keyring with key rotation:

  • Keyring. The constructor takes keys: EncryptionKeyMaterial[] (each { id, key, salt? }) plus an optional activeKeyId, or a legacy single key/salt pair (registered under the id default). The active key is activeKeyId, falling back to the last key in the list. New ciphertext is always written with the active key; any key in the ring can decrypt past values, so rotating in a new active key keeps old data readable.
  • Key handling. A key matching /^[0-9a-f]{64}$/i (a raw 32-byte hex key) is used directly. Any other key is treated as a passphrase and a 32-byte key is derived via scrypt (N = 2^16, r = 8, p = 1), which requires an explicit non-empty salt; a missing or empty salt throws ENCRYPTION_SALT_REQUIRED.
  • Envelope. encrypt produces v1:<keyId>:base64(iv):base64(tag):base64(ciphertext) with a random 12-byte IV per message; the AAD is v1:<keyId>, binding both the version and the key id to the ciphertext. decrypt reads the key id out of the envelope to select the matching key.
  • Error codes. An empty/whitespace key is rejected with ENCRYPTION_KEY_REQUIRED. An invalid key id (not matching /^[A-Za-z0-9._-]+$/) throws ENCRYPTION_KEY_ID_INVALID. An activeKeyId absent from the ring throws ENCRYPTION_ACTIVE_KEY_UNKNOWN. Decrypting an envelope whose key id is not in the ring throws ENCRYPTION_KEY_UNKNOWN. Malformed ciphertext (wrong part count, version, or empty parts) throws ENCRYPTION_INVALID_CIPHERTEXT; a failed decrypt or auth-tag check throws ENCRYPTION_DECRYPT_FAILED.
  • Key generation. generateEncryptionKey() returns a 32-byte raw hex key and is the preferred way to provision a key. legacyDerivedSalt(key) returns sha256('payable.encryption.kdf.v1:' + key) and exists only to recover data encrypted before explicit salts were required - use it as a migration/recovery aid, never for new deployments.

When an encryption driver is configured (PayableConfig.encryption), it is passed to both webhook repositories (src/infrastructure/storage/knex/knex-storage-driver.ts):

  • The webhook-event repository seals the stored headers before writing and opens them on read (headers are JSON-stringified, redacted, then encrypted at rest).
  • The webhook-endpoint repository seals the endpoint secret on write and opens it on read (knex-webhook-endpoint.repository.ts). The secret column is text so it can hold the longer sealed value.

Without an encryption driver, the same fields are stored in plaintext.

Header redaction for logging and storage

redactHeaders (src/support/redact-headers.ts) drops a fixed set of sensitive headers (case insensitive) before headers are persisted or logged:

const SENSITIVE_HEADERS = new Set([
  'authorization', 'proxy-authorization', 'cookie', 'set-cookie',
  'stripe-signature', 'paddle-signature',
]);

StoreWebhookEventAction applies it to incoming webhook headers before they are stored, so the signature header and any auth cookies never land in storage even when encryption is off.

Security assumptions and boundaries

  • The library trusts the billable and paymentId supplied in a request. It does not check that the authenticated caller owns them.
  • The library does not read environment variables; secrets (provider keys, encryption key, Redis connection) are passed in by you.
  • The webhook route trusts only the provider signature, not the network origin.

Threat-to-control table

ThreatControl in libraryCaller responsibility
Forged webhook payloadProvider signature verification before any write (verifyWebhook)Configure the correct signing secret and provider
SSRF via an attacker-controlled outbound endpointOutbound delivery resolves the host and blocks non-routable IPv4/IPv6 targets; fails closed on DNS error/empty resultRestrict who can register endpoints; prefer egress controls at the network layer
Webhook replay by an unauthorized actorCanReplayWebhookPolicy + tenant match -> WEBHOOK_REPLAY_DENIED (403)Supply a trustworthy ReplayWebhookContext (allowed, actorId, tenantId)
Sensitive headers leaking into storage/logsredactHeaders strips auth/signature/cookie headersAvoid logging raw requests elsewhere
Stored webhook headers readable at restOptional AES-256-GCM encryption of header payloadConfigure an encryption driver with a high-entropy key
Unauthenticated checkout/subscription/refund requestNone - routes are openAuthenticate the request (your middleware/guards)
Caller acting on a billable they do not ownNone - billable/paymentId are trustedVerify ownership before delegating to the facade
Cross-tenant accessWebhook replay enforces tenant match; tenancy requires a tenant id when enabled (TENANT_REQUIRED)Pass the correct tenantId; scope queries to your tenant