Skip to main content
Security in a billing system isn’t a feature, it’s the substrate. Duro’s posture rests on a few structural choices and a documented audit pass that found — and fixed — real issues.

Isolation by construction

The strongest control is the one you can’t forget to apply. Tenant isolation is structural, not a runtime check:
  • Modes are separate schemas, not a column. A data('test') client cannot read live rows. Data model →
  • Every repository is tenant-scoped at construction. There’s no repository method that omits tenantId. Request lifecycle →
  • Idempotency keys are per-tenant (@@unique([tenantId, key])) — closing a cross-tenant replay vector found early in the build.

RBAC

Dashboard members have one of five roles; the PermissionGuard gates every write: Machine credentials (sk_ keys, OAuth tokens) carry scope * — a server integration acts with full authority within its one tenant. Human members are role-gated. Sensitive roles (owner, admin) are flagged for 2FA enforcement. Team changes — invite, role change, removal — are written to an audit log, and the owner is protected (can’t be demoted or removed, can’t remove yourself).

Secrets, hashed and timing-safe

  • API keys and OAuth secrets are stored as sha256 hashes; the plaintext is shown once at creation and never again.
  • OTP codes are stored hashed; verification is a timing-safe compare.
  • Webhook signing secrets are shown once; signatures are HMAC-SHA256 with a timing-safe verify and timestamp tolerance.
  • Real provider credentials (WhatsApp token, SMTP, R2) live only in gitignored .env.local — never committed. The staff KYC key is compared timing-safe.

The audit pass — real findings, real fixes

A deliberate adversarial review found issues a casual build would ship. Each was fixed and is covered by tests.
A webhook url was validated only as a URL, and the delivery response body is stored and shown in the inspector. A merchant could point it at 169.254.169.254 and read cloud-metadata IAM credentials. Fixed with WebhookUrlGuard: require https, block private/loopback/link-local/CGNAT/metadata hosts (v4 + v6) at creation, and re-resolve DNS at delivery to defeat rebinding. Webhooks →
Customers could be flagged blacklisted but nothing enforced it. Fixed: subscription creation and checkout both reject blacklisted customers — and checkout rejects before the charge, so no money moves for a banned customer.
The idempotency record was globally unique on key. Fixed to @@unique([tenantId, key]).
Members originally received a wildcard scope. Fixed: members get role-derived scopes and merchant writes are behind PermissionGuard.
Two request-scoped paths (the portal listing and checkout token lookup) iterated both modes. Fixed: the portal takes the current mode explicitly; checkout derives mode from the token prefix. Only background scanners loop both modes.

Performance is part of the posture

A few fixes from the same pass that keep the system honest at scale: recovered/at-risk revenue is summed in the database rather than loaded into Node; the portal listing was de-N+1’d to four batched queries; and indexes were added for the new hot paths (customer phone/email, subscription customerId). Multi-tenancy →

What’s deliberately deferred

The charge gateway is a deterministic simulator; the live payment-rail integration (and the money-movement side of refunds and payouts) lands in the July window. Everything in this page — isolation, RBAC, secrets, the audit fixes — is in place today. Next: a deeper look at the SSRF guard and egress controls.