PushBackLog

Fail Secure

Soft enforcement Complete by PushBackLog team
Topic: security Topic: reliability Skillset: backend Skillset: frontend Technology: generic Stage: execution Stage: review

Fail Secure

Status: Complete
Category: Security
Default enforcement: Soft
Author: PushBackLog team


Tags

  • Topic: security, reliability
  • Skillset: backend, frontend
  • Technology: generic
  • Stage: execution, review

Summary

Fail secure (also called fail closed or fail safe) means that when a system encounters an error in a security-relevant operation, it defaults to the more restrictive, safer state rather than the more permissive one. An authorisation check that throws an unexpected exception should deny access, not grant it. A feature flag service that is unavailable should disable the feature, not enable it. A payment processing error should not complete the transaction. When a system fails, the failure should not create an opening.


Rationale

Failures in security code are not neutral

In non-security code, an unexpected error is typically handled with logging and a generic error response — the consequence is a failed operation. In security code, an unhandled error can have a more serious consequence: if the code path that was supposed to enforce a restriction failed silently, the restriction may have been skipped entirely.

Consider an authorisation middleware that calls an external permission service. If the call fails and the middleware catches the exception and calls next() to continue processing the request, the access check has been bypassed for all requests during the outage. This is fail-open: the failure opens the gate. The correct behaviour is fail-closed: deny the request and return a safe error.

The default is commonly wrong

Most error handling defaults are fail-open because developers optimise for availability and user experience during development. An error in a discount code validation might be caught and treated as “code not found” — granting the original price rather than the discounted one. An error in a subscription tier check might be caught and treated as “highest tier” rather than “deny access.”

Reviewing code for fail-open conditions in security-critical paths is a specific security review activity, not a consequence of general error handling quality.


Guidance

Identifying fail-open conditions

Look for security-relevant operations in try/catch blocks where the catch handler continues the happy path:

// WRONG — fail open in authorisation check
async function checkPermission(userId: string, resource: string): Promise<boolean> {
  try {
    return await permissionService.check(userId, resource);
  } catch (e) {
    logger.warn({ e }, 'Permission check failed');
    return true; // DANGEROUS: grants access on error
  }
}

// CORRECT — fail closed
async function checkPermission(userId: string, resource: string): Promise<boolean> {
  try {
    return await permissionService.check(userId, resource);
  } catch (e) {
    logger.error({ e, userId, resource }, 'Permission check failed — denying');
    return false; // Safe default: deny access when uncertain
  }
}

Common fail-open patterns to eliminate

ScenarioFail-open (dangerous)Fail-closed (correct)
Authorisation service unavailableGrant accessDeny access with 503
Feature flag service unavailableEnable featureDisable feature (safe default)
Rate limiter throws exceptionAllow requestDeny request (conservative)
Payment amount validation failsProcess paymentReject transaction
Signature verification throwsAccept payloadReject payload
Session lookup failsCreate new session with existing claimsRequire re-authentication
Multi-factor auth service unavailableSkip MFA checkBlock login

Principle applied broadly

Fail secure extends beyond authorisation:

// Input validation: fail closed
function parseCurrency(input: unknown): 'GBP' | 'USD' | 'EUR' {
  const allowed = ['GBP', 'USD', 'EUR'] as const;
  if (!allowed.includes(input as any)) {
    throw new ValidationError('Invalid currency'); // Reject, never default to a currency
  }
  return input as 'GBP' | 'USD' | 'EUR';
}

// Webhook signature: fail closed
async function handleWebhook(req: Request): Promise<void> {
  const isValid = validateSignature(req.body, req.headers['x-signature']);
  if (!isValid) {
    // Never process on validation failure — even if it means missing an event
    logger.warn('Invalid webhook signature — rejecting');
    throw new UnauthorizedError();
  }
  await processWebhookEvent(req.body);
}

Secure defaults in configuration

Fail secure also applies to system configuration:

  • New IAM roles and policies should start with zero permissions (fail closed); add permissions explicitly
  • New API endpoints should require authentication unless explicitly marked public
  • New feature flags should default to disabled (not enabled)
  • New service-to-service connections should be denied by network policy until explicitly allowed

Review checklist

  • Authorisation failures default to deny, not allow
  • Exception handlers in security-critical paths do not grant access or continue privileged operations
  • Feature flags default to the safe/disabled state when the flag service is unavailable
  • New IAM policies, network policies, and API routes start from a closed/deny default
  • Signature and token validation failures always reject the request
  • Security-relevant error conditions produce alerts, not silent degradation