← Blog
Engineering · Security

What we found auditing our own code.

·12 min read·by Cards402 engineering

We ran a systematic adversarial audit of the entire Cards402 codebase over two days. Every source file in the backend, every security-critical module in the SDK, the web frontend. ~95 commits. The backend test suite went from 488 to 1,038. Here is what we found, why it was there, and the three patterns that kept recurring.

The shape of the audit

The method was simple: pick a file, read it adversarially, find a real bug, fix it, write a regression test, run the full suite, commit. Then pick the next file. No time-boxing per file, no prioritisation by “risk score” — just a linear sweep of every module with enough logic to hide a bug.

The backend has 46 source files. 42 were modified with fixes. The remaining four are either dead code (zero callers), static data (a hardcoded merchant catalog), or middleware that was already clean and got test coverage from an adjacent cycle. The SDK has 12 source files; every one with meaningful logic was audited.

The worst bugs

1. Treasury-loss race in the reconciler

The reconciler’s hard-fail path did an unconditional UPDATE orders SET status='failed' WHERE id = ?. Between the reconciler’s SELECT and that UPDATE, the VCC callback handler could atomically claim the same row as delivered, store the card, and fire the delivery webhook. The reconciler’s unconditional UPDATE then overwrote delivered with failed and called scheduleRefund.

The agent got both the card and a refund. Treasury drained on every order caught in the window. The fix was the same atomic-claim pattern the VCC callback handler already used: WHERE status = 'ordering' + check changes === 0. Two sites needed it — both the retry-based reconciler and the VCC-poll recovery path.

2. Refund ignoring overpayment

When an agent overpays (common — agents round up for safety), the delta is tracked in order.excess_usdc. But the refund path sent only order.amount_usdc— the quoted amount. Every failed overpaid order silently kept the excess. The fix sums both columns in BigInt stroop precision (Stellar’s native 7-decimal representation) and refunds the total.

3. API key that never expires

The expiry check was new Date(candidate.expires_at) < new Date(). If expires_at was corrupt (bad ISO string, ops typo), new Date() returned Invalid Date, and NaN < number evaluates to false in JavaScript. The key silently never expired. Fix parses through getTime(), requires Number.isFinite, and fails closed.

4. Hardcoded mainnet URLs and issuers

The funding-check poller, the Soroban submitSorobanTx Horizon fallback, and the getOWSBalancehelper all had the Circle mainnet USDC issuer and Horizon URL hardcoded instead of reading from the environment. Cards402 runs on mainnet in both production and development, so this wasn’t causing live failures — but the code was fragile: if anyone ever deployed against testnet for integration testing, USDC funding detection would silently break and the SDK’s Horizon fallback would return false “dropped” signals. We made all three env-configurable for consistency with the rest of the codebase (which already reads STELLAR_USDC_ISSUER and STELLAR_NETWORK from env).

Three recurring patterns

After ~95 fixes, the bugs cluster into three families. Recognising the pattern makes it easier to spot the next instance before it ships.

Pattern 1: “Node gives you string | string[]”

Node’s HTTP parser returns an array for duplicated headers. Most code assumes a string. We found this bug in seven independent call sites: requireAuth, /auth/me, /auth/logout, recordAuditFromReq, recordAudit (direct callers), vcc-callback audit rows, and the app-level X-Request-ID middleware. Each one crashed to 500 or silently dropped a row. The durable fix is to validate at the library boundary (recordAudit now coerces internally) rather than auditing every caller.

Pattern 2: “The catch block can’t crash”

JavaScript lets you throw null, throw 'string', or throw an Error whose .message getter itself throws. Any catch(err) that reads err.message without a guard will crash on its own error-handling path. We found this in the payment handler (left orders wedged in ordering status with no refund), the retry helper (ran only one attempt instead of three), and the sanitize-error module (the error sanitiser itself threw). The pattern: extract a safeErrorMessage(err) helper that handles null, undefined, strings, getter-thrown messages, and revoked Proxies.

Pattern 3: “The circuit breaker has a race”

Both the VCC client and the webhook delivery layer had the same in-flight-success race: a request that started before the breaker tripped could complete successfully during cooldown and call recordSuccess(), which unconditionally zeroed openedUntil. The next caller saw an open gate and hit the still-broken upstream. Fix: leave openedUntil alone while Date.now() < openedUntil; zero the failure counter unconditionally so the next post-cooldown window starts fresh.

By the numbers

Commits~95
Backend files modified42 / 46
SDK files audited10 / 12
Backend tests before~488
Backend tests after1,038
Treasury-safety fixes3
Auth / identity fixes7
Circuit breaker fixes3
Silent-error-loss fixes8
Testnet-correctness fixes3
DoS-from-config fixes4

What the audit did not cover

This was a source-code audit, not a pentest. We didn’t test the live deployment (infrastructure, TLS termination, Cloudflare rules, DNS), the VCC scraper service (separate codebase), the Soroban receiver contract (needs a specialist cryptographic review), or dependency supply-chain integrity. We also didn’t load-test the SQLite write path under genuine multi-connection pressure — we added PRAGMA busy_timeout to close the obvious gap, but the real proof requires sustained concurrent traffic. Those are the next steps.

The full list

Every fix has a detailed commit message with the pre-fix code, the exploit scenario, and the rationale for the specific defense. The changelog has the categorised summary; the git log has the per-finding detail.