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
Databaseclass (packages/db/src/database.ts) holds one client for thecoreschema and lazily constructs a second data client per mode, each pointed at a different schema (core,live,sandbox) through the?schema=connection-string parameter. Modetestresolves to thesandboxschema. 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’smultiSchemapreview 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 appliestenantIdin itswhereclauses (for exampleCustomerRepository.findByIdfilters{ id, tenantId }). There is no repository accessor that omits the tenant. - Idempotency keys are per-tenant.
IdempotencyRecordis@@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 inpackages/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
sha256hex hashes (Hash.sha256Hex,ApiKeyFactoryinpackages/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.verifyHashedSecretusestimingSafeEqual). - 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’sipAllowlistis 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
sha256hashes; 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 uniquesha256hash (bvnHash) used only for duplicate detection (portal-email.service.tscallsCryptor.encryptJsonfrompackages/crypto/src/cryptor.tsandsetBvn). The plaintext is never returned to the browser: the portal exposes onlyhasBvn: !!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-safeHash.verifyHashedSecret, but the member-facingActionOtpandWebhookSecretOtppaths do not.
Webhook signing (outbound, Duro → merchant)
Every outbound delivery is signed byWebhookSigner (packages/crypto/src/secrets.ts) and sent in the duro-signature header:
"<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 byNombaWebhook.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
sha256hash is stored on theSessionrow and used as the cache key; the raw token lives only in theduro_sessioncookie (orx-duro-sessionheader). 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 itsexpiresAt, 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 fromcreatedAt.- Validated sessions are cached for 30 seconds to cut database load;
lastUsedAtis touched at most every 30 seconds. - If the tenant’s policy sets
enforceTwoFactorand the member has not enabled a second factor, non-auth routes are blocked with403until they do.
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.
Fixes from the adversarial review
A deliberate review surfaced issues a casual build would ship. Each is fixed and covered by tests.HIGH: Webhook SSRF to metadata exfiltration
HIGH: Webhook SSRF to metadata exfiltration
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 →MEDIUM: Blacklist was decorative
MEDIUM: Blacklist was decorative
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.Cross-tenant idempotency replay
Cross-tenant idempotency replay
The idempotency record was globally unique on
key. Fixed to @@unique([tenantId, key]).RBAC bypass on merchant writes
RBAC bypass on merchant writes
Members originally received a wildcard scope. Fixed: members get role-derived permissions and merchant writes sit behind
PermissionGuard.Mode-scoping leaks
Mode-scoping leaks
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.
Related performance fixes
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.