Skip to main content
Security in a billing system is the substrate, not a feature. This page documents the controls Duro actually implements today, with file references, and is explicit about where a control is partial or where a known weakness remains. It is written for a reviewer who will read the code.

Isolation by construction

The strongest control is the one you cannot forget to apply. Tenant and mode isolation are structural.
  • Live and test data live in separate Postgres schemas, not behind a mode column. The Database class (packages/db/src/database.ts) holds one client for the core schema and lazily constructs a second data client per mode, each pointed at a different schema (core, live, sandbox) through the ?schema= connection-string parameter. Mode test resolves to the sandbox schema. A data client opened for one mode physically cannot read the other mode’s rows. This is schema-level separation via per-client connection strings; it does not use Prisma’s multiSchema preview feature.
  • Every repository is tenant-scoped at construction. RepositoryContext (packages/db/src/repositories/context.ts) builds every repository with (dataClient, tenantId, mode, cache), and each repository applies tenantId in its where clauses (for example CustomerRepository.findById filters { id, tenantId }). There is no repository accessor that omits the tenant.
  • Idempotency keys are per-tenant. IdempotencyRecord is @@unique([tenantId, key]) (packages/db/prisma/data.prisma), so one tenant’s key cannot collide with or replay against another’s.

RBAC

Dashboard members have one of five roles, defined in packages/auth/src/rbac.ts: The role-to-permission map is exactly as drawn above (Rbac.ROLE_PERMISSIONS). Writes are gated by PermissionGuard.require(<permission>) on the route; for example every mutating webhook route requires webhook:manage (packages/merchant-api/src/webhooks/controller.ts). API-key and OAuth credentials authenticate as a machine and carry scope ['*'] within their one tenant and mode (packages/http-kit/src/middleware/api-key-auth.ts): a server integration acts with full authority, but only inside that tenant. Human members are role-gated. owner and admin are the roles flagged for 2FA enforcement (Rbac.TWO_FACTOR_ENFORCED). The owner is structurally protected: team.service.ts rejects changing the owner’s role, removing the owner, and removing yourself. Team changes are written to an audit log (member.invited, member.role_changed, member.removed via recordAudit, readable through listAudit).

Secrets and how they are stored

  • API keys and OAuth client secrets are stored only as sha256 hex hashes (Hash.sha256Hex, ApiKeyFactory in packages/crypto/src/secrets.ts). The plaintext is returned once at creation and never again. A presented key is looked up by hashing it and matching the stored hash; the equality check for hashed secrets is timing-safe (Hash.verifyHashedSecret uses timingSafeEqual).
  • API keys carry a stored prefix and an optional IP allowlist. The prefix (sk_live_ / sk_test_ / pk_... plus the first six characters of the random body) is stored for display; the full key is never stored in clear. If a key’s ipAllowlist is non-empty, requests are rejected unless the caller IP matches an entry. Matching supports exact IPv4/IPv6 literals and IPv4 CIDR ranges only (packages/http-kit/src/lib/ip-allowlist.ts); IPv6 CIDR is not implemented, and an empty allowlist means “allow any IP”.
  • Webhook signing secrets (whsec_...) are shown once at creation and on rotation. Rotation is a sensitive action gated by password re-entry plus an emailed OTP (WebhookController.rollSecret).
  • Session tokens are stored only as sha256 hashes; see Session security.
  • A customer’s BVN (collected only to issue a virtual account) is encrypted at rest with ChaCha20-Poly1305 and stored as a base64 blob (bvnCipher), alongside a unique sha256 hash (bvnHash) used only for duplicate detection (portal-email.service.ts calls Cryptor.encryptJson from packages/crypto/src/cryptor.ts and setBvn). The plaintext is never returned to the browser: the portal exposes only hasBvn: !!account.bvnHash.
  • Real provider credentials (WhatsApp token, SMTP, R2, Nomba) are read from environment and are kept in gitignored .env.local, not committed.

The OTP caveat (read this)

Email and portal one-time codes are stored hashed. ActionOtp / CriticalOtp / WebhookSecretOtp write the code as a sha256 hash into the cache with a 10-minute TTL, cap attempts at five, and rate-limit issuance. One thing is worth stating plainly:
  • The comparison against the stored hash is a plain string inequality (this.hash(code) !== record.codeHash), not a constant-time compare. The customer-identity and portal flows go one better and use the timing-safe Hash.verifyHashedSecret, but the member-facing ActionOtp and WebhookSecretOtp paths do not.

Webhook signing (outbound, Duro → merchant)

Every outbound delivery is signed by WebhookSigner (packages/crypto/src/secrets.ts) and sent in the duro-signature header:
The signed string is "<timestamp>.<body>" where body is the exact JSON bytes that are POSTed. Verification recomputes the HMAC, enforces a default tolerance window of 300 seconds on |now - t|, and compares with timingSafeEqual. Malformed headers and length-mismatched digests fail closed.

Inbound Nomba webhook verification

Inbound events from the payment provider are verified by NombaWebhook.verify (packages/nomba-client/src/webhook.ts). The signature is not an HMAC over the raw body. It is an HMAC-SHA256, keyed by the shared secret, over a colon-delimited concatenation of specific fields, event_type : requestId : merchant.userId : merchant.walletId : transaction.transactionId : transaction.type : transaction.time : transaction.responseCode : <header timestamp>, digested as base64 and compared to the header signature with a timing-safe check. A request missing the signature or timestamp header is rejected.

Idempotency

IdempotencyMiddleware (packages/http-kit/src/middleware/idempotency.ts) applies to mutating methods (POST/PUT/PATCH/DELETE). By default an Idempotency-Key header is required; without it the request is rejected with 400. The key is scoped to (tenantId, mode). On first use the response is recorded (status + body) keyed by a hash of method:url:rawBody; a replay with the same key but a different request hash is rejected with an idempotency conflict (422), and a replay with the same hash returns the stored response verbatim.

Session security

Dashboard sessions use an opaque random token, never a self-describing JWT (packages/auth/src/session.ts):
  • The token is 32 random bytes (base64url). Only its sha256 hash is stored on the Session row and used as the cache key; the raw token lives only in the duro_session cookie (or x-duro-session header). Presented tokens are matched timing-safe (SessionToken.matches).
  • RequireMemberMiddleware (apps/core-api/src/middleware/require-member.ts) rejects a session that is revoked or past its expiresAt, enforces an idle timeout (default 30 minutes, tenant-configurable 5–1440 min) by revoking on inactivity, and enforces an optional absolute timeout (tenant-configurable 1–720 hours; off by default) measured from createdAt.
  • Validated sessions are cached for 30 seconds to cut database load; lastUsedAt is touched at most every 30 seconds.
  • If the tenant’s policy sets enforceTwoFactor and the member has not enabled a second factor, non-auth routes are blocked with 403 until they do.
Second factors: TOTP (RFC 6238, Totp) and email OTP are supported, with single-use hashed backup codes (hashBackupCode, consumed on use). See the OTP caveat above for the email-OTP limitations.

Login location and security alerts

The owner is emailed when something security-relevant happens:
  • On sign-in, the login records IP, parsed device, and (only if the browser grants it) geo-coordinates onto the Session, and emails a security alert with the IP, device, and a Google Maps link.
  • On 2FA enable / disable, passkey add / remove, and webhook-secret rotation, an alert email fires so a change to a security control cannot happen silently.
Geolocation is capture-only; it never blocks. The login requests browser location purely to enrich the alert. If the prompt is denied, sign-in proceeds unchanged and the alert falls back to an IP-derived city. Location is a signal for a human, never a gate.

Network edge

Public traffic to Duro is proxied through Cloudflare before it reaches origin. DNS for the platform is served from Cloudflare with the proxy enabled, so every request terminates TLS at the nearest Cloudflare edge and is forwarded to origin over an encrypted hop. This gives the platform several properties without any application change:
  • Origin concealment. The VPS and Vercel origins are never addressed directly. Clients only ever see Cloudflare edge IPs, so the origin is not a public target.
  • Edge TLS and modern transport. Certificates are managed at the edge, and HTTP/2 and HTTP/3 are available everywhere without touching origin.
  • DDoS and volumetric protection. Cloudflare absorbs L3 and L4 floods and offers L7 rate limiting in front of the API, so abusive traffic is shed before it reaches an origin worker.
  • One choke point for WAF and rate rules. Blocklists, bot rules, and per-route rate limits apply uniformly at the edge, independent of the application.
Application-layer controls (idempotency, RBAC, SSRF egress guards, session security) still apply at origin. The edge is defence in depth, not a replacement for them.

Fixes from the adversarial review

A deliberate review surfaced issues a casual build would ship. Each is fixed and covered by tests.
A webhook url was validated only as a well-formed URL, and the delivery response body is stored and shown in the inspector, so a merchant could point it at 169.254.169.254 and read cloud metadata. Fixed with WebhookUrlGuard: require https and block private / loopback / link-local / CGNAT / metadata hosts (v4 and v6) at creation, and re-resolve DNS at delivery to raise the bar against rebinding. A residual TOCTOU window remains between the guard’s DNS lookup and fetch’s own resolution. SSRF & Egress →
Customers could be flagged blacklisted with nothing enforcing it. Fixed: subscription creation (packages/merchant-api/src/subscriptions/service.ts) and checkout (apps/public-api/src/services/checkout.service.ts) both reject blacklisted customers, and checkout rejects before reserving 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 permissions and merchant writes sit behind PermissionGuard.
Two request-scoped paths (the portal listing and the checkout token lookup) iterated both modes. Fixed: the portal takes the current mode explicitly, and checkout derives its mode from the token. Only background scanners loop both modes.
From the same pass, correctness-relevant under load: recovered / at-risk revenue is summed in the database rather than loaded into Node; the portal listing was de-N+1’d to a small fixed set of batched queries; indexes were added for the new hot paths; and wallet debits are guarded conditional updates so the balance stays non-negative under concurrency. Multi-tenancy →

Test and live environments

Duro runs two environments that the merchant selects, the standard test/live separation of any payment platform. The request mode selects the Nomba host: test targets the sandbox host (sandbox.nomba.com) and live targets production (api.nomba.com), chosen by the API-key prefix (sk_test_ / sk_live_) or the x-duro-mode header. In both, the charge, refund, payout, virtual-account, direct-debit, and VAS paths drive the real Nomba APIs (packages/nomba-client); the SimulatedChargeGateway (packages/payments) is used only by the internal integration test suite. In test mode, refunds and payouts short-circuit to a deterministic result by design, so the money-movement side can be exercised end to end without moving real funds. The isolation, RBAC, secret-handling, session, SSRF, and audit-fix controls described above are implemented in both environments. Next: a deeper look at the SSRF guard and egress controls.