A failed authorization denial, insufficient-funds rejection, or rate-limit block is not a neutral event — it is a potential indicator of credential stuffing, account takeover, or fraud probing. If it is not logged with a reason code, your security team has no signal to detect these patterns. PCI-DSS 4.0 Req-10.2.1.7 explicitly requires logging of invalid logical access attempts. NIST 800-53 AU-2 requires logging of failed login attempts and failed authentication events. FINRA Rule 4511 requires complete books and records, which include rejected transactions. CWE-778 (Insufficient Logging) covers the failure to record security-relevant events. An attacker probing for accounts with insufficient-funds errors on micro-transactions to enumerate valid account IDs will leave no trace if rejections are silently swallowed.
Low because failed-transaction logging is a detective control — its absence does not enable attacks directly, but it eliminates the forensic evidence needed to detect and investigate fraud after the fact.
Wrap every failure path in src/services/transaction.ts with an explicit log write before throwing, and define a fixed set of reason codes as a TypeScript union or enum:
type FailReason =
| 'INSUFFICIENT_FUNDS'
| 'ACCOUNT_FROZEN'
| 'AUTHORIZATION_DENIED'
| 'INVALID_INPUT'
| 'RATE_LIMITED'
| 'ACCOUNT_NOT_FOUND';
async function logFailure(
userId: string, operation: string, amount: number, reason: FailReason
) {
await db('transaction_logs').insert({
user_id: userId, operation_type: operation, amount,
result: 'fail', reason_code: reason,
timestamp: new Date().toISOString()
});
}
// In executeTransfer:
if (account.balance < amount) {
await logFailure(userId, 'transfer', amount, 'INSUFFICIENT_FUNDS');
throw new TransactionError('Insufficient funds');
}
A catch-all block at the route level does not satisfy this requirement — each named failure condition needs its own reason_code recorded before the exception propagates.
ID: finserv-audit-trail.retention-compliance.failed-transaction-logging
Severity: low
What to look for: Count all error/failure handling paths in financial transaction endpoints. For each failure path, check whether a log entry is written with a reason code. Enumerate the distinct reason codes used (e.g., INSUFFICIENT_FUNDS, AUTHORIZATION_DENIED, INVALID_INPUT). Quote the actual reason code values found. A catch block that silently swallows errors without logging does not count as pass.
Pass criteria: At least 90% of failure handling paths write to the audit log with a reason code. Count all distinct reason codes — at least 3 must exist. Report the ratio even on pass (e.g., "7 of 8 failure paths logged, 5 distinct reason codes: INSUFFICIENT_FUNDS, AUTH_DENIED, INVALID_INPUT, RATE_LIMITED, ACCOUNT_FROZEN").
Fail criteria: Failed transactions are not logged, or failure reasons are omitted from the audit trail, or fewer than 3 distinct reason codes exist.
Skip (N/A) when: Never — failures are regulatory events that must be tracked.
Detail on fail: "Failed transactions logged in 2 of 6 failure paths — reason codes present in 0 paths." or "Authorization denials not logged — 0 of 3 auth failure paths write to audit log.".
Remediation: Ensure all failures are logged with reasons (in src/services/transaction.ts or equivalent):
async function executeTransfer(userId, toAccountId, amount) {
// Validation
const account = await db.accounts.findOne({ userId });
if (!account) {
await db.transactionLogs.create({
userId,
operationType: 'transfer',
amount,
result: 'fail',
reason: 'ACCOUNT_NOT_FOUND',
timestamp: new Date().toISOString()
});
throw new Error('Account not found');
}
if (account.balance < amount) {
await db.transactionLogs.create({
userId,
operationType: 'transfer',
amount,
result: 'fail',
reason: 'INSUFFICIENT_FUNDS',
detail: `Balance: ${account.balance}, Requested: ${amount}`,
timestamp: new Date().toISOString()
});
throw new Error('Insufficient funds');
}
// Execute transfer
// ... success logging ...
}