ity policy evaluation from request routing, explicitly defining fallback contracts, and instrumenting degradation events. The following implementation demonstrates a production-grade approach using TypeScript.
Step 1: Define Explicit Fallback Contracts
Fallback behavior must be declared at the policy level, not buried in error handlers. This ensures auditable configuration and prevents implicit defaults.
export enum FallbackMode {
DENY = 'DENY',
ALLOW = 'ALLOW',
QUEUE = 'QUEUE'
}
export interface SecurityPolicy {
id: string;
evaluate(context: RequestContext): Promise<boolean>;
fallback: FallbackMode;
degradationTimeoutMs: number;
}
Step 2: Build a Policy Evaluation Engine
The engine intercepts requests, executes the policy, and routes failures according to the configured fallback mode. It separates business logic from security enforcement.
export class PolicyEngine {
private policies: Map<string, SecurityPolicy> = new Map();
register(policy: SecurityPolicy): void {
this.policies.set(policy.id, policy);
}
async enforce(policyId: string, context: RequestContext): Promise<EnforcementResult> {
const policy = this.policies.get(policyId);
if (!policy) {
throw new Error(`Policy ${policyId} not registered`);
}
try {
const isAuthorized = await Promise.race([
policy.evaluate(context),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Policy evaluation timeout')), policy.degradationTimeoutMs)
)
]);
return { status: 'ALLOWED', policyId, reason: 'Evaluation succeeded' };
} catch (error) {
return this.handleDegradation(policy, context, error as Error);
}
}
private handleDegradation(
policy: SecurityPolicy,
context: RequestContext,
error: Error
): EnforcementResult {
switch (policy.fallback) {
case FallbackMode.DENY:
return { status: 'DENIED', policyId: policy.id, reason: `Fallback deny: ${error.message}` };
case FallbackMode.ALLOW:
return { status: 'ALLOWED', policyId: policy.id, reason: `Fallback allow: ${error.message}` };
case FallbackMode.QUEUE:
this.enqueueForReview(policy.id, context, error);
return { status: 'QUEUED', policyId: policy.id, reason: 'Deferred for manual review' };
default:
throw new Error(`Unknown fallback mode: ${policy.fallback}`);
}
}
private enqueueForReview(policyId: string, context: RequestContext, error: Error): void {
// Integration with message broker or persistent queue
console.warn(`[PolicyEngine] Queued ${policyId} for review. Context: ${JSON.stringify(context)} | Error: ${error.message}`);
}
}
Step 3: Integrate with Request Middleware
The middleware layer applies the engine to incoming requests, ensuring consistent enforcement across routes.
export async function securityMiddleware(
engine: PolicyEngine,
policyId: string,
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const context: RequestContext = {
userId: req.headers['x-user-id'] as string,
resource: req.path,
method: req.method,
timestamp: Date.now()
};
const result = await engine.enforce(policyId, context);
if (result.status === 'DENIED') {
res.status(403).json({ error: 'Access denied', reason: result.reason });
return;
}
if (result.status === 'QUEUED') {
res.status(202).json({ message: 'Request accepted for review', queueId: result.reason });
return;
}
// ALLOWED or fallback allow
res.locals.securityContext = context;
next();
}
Architecture Decisions & Rationale
- Explicit Fallback Enumeration: Using a strict enum prevents accidental permissive defaults. Developers must consciously select
ALLOW, DENY, or QUEUE.
- Timeout Enforcement:
Promise.race ensures that slow or hanging policy evaluations do not block request threads indefinitely. This prevents cascading latency during dependency degradation.
- Queue Mode for Write Operations: Mutating operations that touch sensitive data use
QUEUE fallback. This preserves security boundaries while preventing data loss. Requests are deferred for human or automated reconciliation.
- Separation of Concerns: The
PolicyEngine handles evaluation and routing. Middleware handles HTTP translation. This allows the same policy logic to be reused across REST, GraphQL, and internal service-to-service calls.
Pitfall Guide
1. Implicit Permissive Defaults in Catch Blocks
Explanation: Developers often write catch { return true } or omit return statements, causing the runtime to default to permissive behavior. This is the most common source of accidental fail-open vulnerabilities.
Fix: Never rely on implicit returns. Always explicitly declare the fallback state. Use static analysis rules to flag catch blocks that return truthy values without explicit policy context.
2. Local Token Caching Without Revocation Awareness
Explanation: Caching authentication tokens locally to survive identity provider outages improves availability but creates a window where revoked or expired tokens remain valid.
Fix: Implement short-lived local caches with explicit staleness thresholds. Pair caching with a background revocation sync job. Never cache beyond the token's remaining TTL.
3. Treating Observability as a Core Dependency
Explanation: Blocking requests because metrics, logging, or tracing pipelines are unavailable violates the principle of separation between control plane and data plane.
Fix: Design observability as a best-effort sidecar. Use fire-and-forget patterns with local buffering. Never throw or deny requests when telemetry sinks fail.
4. Ignoring Partial Failure States
Explanation: Assuming dependencies are either fully healthy or completely down leads to brittle fallback logic. In reality, services often return degraded responses, partial data, or elevated latency.
Fix: Implement health checks that distinguish between healthy, degraded, and unhealthy. Route fallback behavior based on the specific degradation signal, not a binary up/down state.
5. Hardcoding Fallback Logic in Controllers
Explanation: Embedding fallback decisions directly in route handlers couples security policy to HTTP routing. This makes policies untestable and difficult to audit.
Fix: Extract fallback routing into a dedicated policy engine or middleware layer. Controllers should only interpret the final EnforcementResult.
6. Missing Fallback Audit Trails
Explanation: When a fallback triggers, the event often goes unlogged or is buried in generic error logs. This makes incident response and compliance auditing nearly impossible.
Fix: Emit structured events for every fallback trigger. Include policy ID, fallback mode, error context, and request fingerprint. Route these events to a dedicated security audit stream.
7. Over-Engineering Retry Logic for Security Checks
Explanation: Aggressively retrying failed authentication or authorization checks can amplify downstream load, trigger rate limits, or create timing attacks.
Fix: Limit retries to two attempts with exponential backoff. If the dependency remains unavailable, fall back to the configured mode immediately. Never retry on 403 or 401 responses.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Authentication service outage | Fail-Secure (DENY) | Prevents unauthorized access during identity verification gap | Medium (user friction, support tickets) |
| Analytics/metrics pipeline failure | Fail-Permissive (ALLOW) | Observability is non-blocking; uptime takes precedence | Low (data gap, no security impact) |
| Content publishing workflow | Fail-Deferred (QUEUE) | Preserves editorial control while preventing data loss | Medium (queue infrastructure, review SLA) |
| Payment gateway timeout | Fail-Secure (DENY) | Financial integrity requires strict validation; retries handled by idempotent processors | High (transaction abandonment, but prevents fraud) |
Configuration Template
// security-policies.config.ts
import { FallbackMode, SecurityPolicy } from './policy-engine';
export const securityPolicies: SecurityPolicy[] = [
{
id: 'auth:verify-token',
fallback: FallbackMode.DENY,
degradationTimeoutMs: 2000,
evaluate: async (ctx) => {
// Integration with identity provider
return true;
}
},
{
id: 'cms:publish-content',
fallback: FallbackMode.QUEUE,
degradationTimeoutMs: 3000,
evaluate: async (ctx) => {
// Integration with approval service
return true;
}
},
{
id: 'observability:emit-metrics',
fallback: FallbackMode.ALLOW,
degradationTimeoutMs: 500,
evaluate: async (ctx) => {
// Fire-and-forget metrics sink
return true;
}
}
];
Quick Start Guide
- Install dependencies: Add
@types/node and your preferred HTTP framework. No external security libraries are required for the base implementation.
- Register policies: Import the configuration template and call
engine.register(policy) for each security boundary in your application.
- Attach middleware: Apply
securityMiddleware to protected routes, passing the engine instance and policy ID.
- Validate fallbacks: Use a mock HTTP server to simulate dependency timeouts. Verify that requests are denied, queued, or allowed according to configuration.
- Enable audit logging: Configure your logging pipeline to capture
EnforcementResult objects. Set up alerts for repeated DENY or QUEUE triggers during normal operation.