Opinionated rules for implementing, validating and maintaining Cross-Origin Resource Sharing (CORS) policies in API back-ends.
Tired of wrestling with CORS errors that break your APIs in production? These battle-tested rules eliminate the guesswork from Cross-Origin Resource Sharing implementation, giving you security-first CORS policies that work reliably across Express.js, Go, and ASP.NET Core.
Every API developer has been there: your frontend works perfectly in development, then CORS errors explode in production. You patch with wildcards, accidentally expose sensitive endpoints, and spend hours debugging preflight failures. Meanwhile, security vulnerabilities slip through because CORS policies are scattered across different services with inconsistent rules.
The core problems:
These rules treat CORS as a security control, not a development convenience. You get centralized, auditable policies that fail closed by default, then open precisely as needed. Every rejected request gets logged, preflight responses stay under 200ms, and your CORS policies version alongside your APIs.
The approach is simple: configure once at the outer layer (API gateway or reverse proxy), fall back to service-level config only for special cases, and never compromise on security for convenience.
Eliminate Production CORS Failures
Accelerate Development Velocity
Maintain Security Compliance
Before: Scattered CORS configs, wildcard origins, no logging
// Dangerous - exposes all endpoints to any origin
app.use(cors({ origin: '*' }));
After: Centralized, type-safe CORS with audit logging
// src/middleware/cors.ts
const ALLOWED_ORIGINS: ReadonlyArray<string> = [
'https://app.example.com',
/^https:\/\/.*\.example-company\.com$/
];
export function cors(): RequestHandler {
return (req, res, next) => {
const origin = req.header('Origin');
if (!origin || !ALLOWED_ORIGINS.some(o =>
(o instanceof RegExp ? o.test(origin) : o === origin))) {
res.status(403).json({ error: 'CORS origin forbidden', origin });
audit.logger.warn({ event: 'cors_forbidden', origin, path: req.path });
return;
}
res.header({
'Access-Control-Allow-Origin': origin,
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
'Access-Control-Max-Age': '600'
});
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
};
}
Result: 200ms preflight responses, zero production CORS errors, complete audit trail
Before: Different CORS configs per service, inconsistent security
// Inconsistent and insecure
r.Use(cors.Default())
After: Standardized CORS across all services
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://app.example.com"},
AllowMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type"},
MaxAge: 600,
}))
Result: Consistent security posture, simplified debugging across microservices
Before: Hardcoded origins, no environment separation
// Inflexible and risky
services.AddCors(options => options.AddDefaultPolicy(builder =>
builder.AllowAnyOrigin()));
After: Environment-aware, configuration-driven policies
builder.Services.AddCors(options =>
options.AddPolicy("ProdPolicy", policy =>
policy.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.WithMethods("GET","POST","PATCH","DELETE")
.SetPreflightMaxAge(TimeSpan.FromMinutes(10))));
app.UseCors("ProdPolicy");
Result: Environment-specific security, configuration-driven flexibility
Option A: API Gateway Level (Recommended for Microservices) Configure CORS once in Kong, NGINX, or AWS API Gateway. Internal services then whitelist only the gateway's origin.
Option B: Service Level (Recommended for Monoliths) Implement standardized CORS middleware in each service using the provided templates.
Express.js/NestJS:
src/middleware/cors.ts with the provided implementationapp.use(cors())Go (Gin/Fiber):
go get github.com/gin-contrib/corsASP.NET Core:
builder.Services.AddCors() in Program.csapp.UseCors() before routingCreate configuration files for each environment:
// config/cors-allowed-origins.json
{
"development": ["http://localhost:3000"],
"staging": ["https://staging.example.com"],
"production": ["https://app.example.com"]
}
Create a Postman collection or automated test suite:
// tests/cors/preflight.test.js
describe('CORS Preflight', () => {
it('returns 200 for allowed origins', async () => {
const response = await request(app)
.options('/api/users')
.set('Origin', 'https://app.example.com')
.expect(200);
expect(response.headers['access-control-allow-origin'])
.toBe('https://app.example.com');
});
it('returns 403 for forbidden origins', async () => {
await request(app)
.options('/api/users')
.set('Origin', 'https://evil.com')
.expect(403);
});
});
Add logging for rejected requests and set up alerts for unusual patterns:
// Log every rejected attempt
audit.logger.warn({
event: 'cors_forbidden',
origin,
path: req.path,
ip: req.ip,
userAgent: req.get('User-Agent')
});
Immediate Security Improvements:
Developer Productivity Gains:
Long-term Maintenance Benefits:
Stop fighting CORS errors in production. Implement these rules once, and focus on building features instead of debugging cross-origin failures. Your APIs will be more secure, your frontends will load faster, and your deployment confidence will skyrocket.
You are an expert in Cross-Origin Resource Sharing (CORS) across Node.js/TypeScript (Express.js, NestJS), Go (Gin, Fiber), and ASP.NET Core. These rules standardise secure, auditable CORS handling for REST, GraphQL and serverless APIs.
Key Principles
- CORS is a security control, not a convenience feature—fail closed, then open as needed.
- Configure once, at the outer-most layer (API gateway or reverse-proxy) when possible; fall back to service-level config only for special cases.
- Never use wildcard origins on endpoints that expose authentication tokens or PII.
- Treat pre-flight (OPTIONS) requests as first-class citizens: respond quickly (≤200 ms) and cache aggressively.
- Log every rejected cross-origin attempt with origin, method, headers and client IP.
- Version CORS policy alongside API versions; breaking origin changes require a new minor API version.
JavaScript / TypeScript
- Centralise CORS middleware (e.g. src/middleware/cors.ts) and mount before any route definitions.
- Use strongly-typed allow-lists:
```ts
const ALLOWED_ORIGINS: ReadonlyArray<string> = [
'https://app.example.com',
/^https:\/\/.*\.example-company\.com$/
];
```
- Reject wildcards: TypeScript `never` type guards against '*'.
- Explicit methods: `GET,POST,PATCH,DELETE,OPTIONS`.
- Cache pre-flight for 600 s with `Access-Control-Max-Age`.
- Sample Express middleware:
```ts
export function cors(): RequestHandler {
return (req, res, next) => {
const origin = req.header('Origin');
if (!origin || !ALLOWED_ORIGINS.some(o => (o instanceof RegExp ? o.test(origin) : o === origin))) {
res.status(403).json({ error: 'CORS origin forbidden', origin });
audit.logger.warn({ event: 'cors_forbidden', origin, path: req.path });
return;
}
res.header({
'Access-Control-Allow-Origin': origin,
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
'Access-Control-Allow-Headers': req.header('Access-Control-Request-Headers') || '',
'Access-Control-Max-Age': '600'
});
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
};
}
```
Go (Gin / Fiber)
- Mount `cors.Config{ AllowOrigins: []string{"https://*.example.com"}, AllowMethods: "GET,POST" }` as the first middleware.
- Always send `Vary: Origin`.
- Use `OPTIONS` handlers returning `StatusNoContent` (204).
C# (ASP.NET Core)
- Place `builder.Services.AddCors()` in `Program.cs` with named policies.
- Example:
```csharp
builder.Services.AddCors(options =>
options.AddPolicy("ProdPolicy", policy =>
policy.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.WithMethods("GET","POST","PATCH","DELETE")
.SetPreflightMaxAge(TimeSpan.FromMinutes(10))));
app.UseCors("ProdPolicy");
```
Error Handling and Validation
- Return 200 for successful pre-flight, 204 if no body.
- Return 403 (forbidden) when origin/method/header is not allowed; include machine-parseable JSON:
`{ "error": "cors_forbidden", "origin": "https://evil.com" }`.
- Include `Vary: Origin` on all responses, even errors.
- Log: origin, ip, method, path, rejectedHeader, correlationId.
- Automated health probe every 5 min uses cURL to verify headers.
Framework-Specific Rules
Express.js
- Use `helmet()` but disable its built-in CORS—keep single source of truth in custom middleware above.
- Attach rate-limiting to `/OPTIONS` on auth routes to mitigate pre-flight floods.
NestJS
- Enable CORS globally in `main.ts`, then override per-controller only when narrowing down origin list.
Gin / Fiber
- Use `cors.New()` once; avoid per-route overrides.
- Keep `AllowCredentials` off unless absolutely required; when on, origin cannot be wildcard.
ASP.NET Core
- Use environment-specific policies: `DevOpenPolicy` (localhost only), `ProdPolicy` (exact list), configured via appsettings.json.
- Validate that any policy with `AllowCredentials` does not also use `SetIsOriginAllowed(_ => true)`.
Additional Sections
Testing
- Postman collection `cors_tests.json` with env variables `{{origin}}` and `{{method}}` exercises happy path and forbidden scenarios.
- `npm test:cors` runs Jest + Supertest suite asserting headers and status codes.
- CI step `curl -X OPTIONS https://api.example.com/users -H "Origin:https://app.example.com" -i` fails pipeline if headers missing.
Performance
- Cache pre-flight (`Access-Control-Max-Age: 600`) unless credentials are required.
- For large microservice sets, terminate CORS at API gateway (Kong, NGINX). Internal services then whitelist only the gateway’s origin.
Security
- For credentialed requests (`withCredentials: true`), set `SameSite=None` and require HTTPS.
- Maintain allow-list in configuration store (Vault, AWS SSM); rotation PR must include security review.
- Never reflect request headers back unchecked (`Access-Control-Allow-Headers: *`). Enumerate explicitly.
Common Pitfalls
- Forgetting `Vary: Origin` causes cache poisoning—mandate via ESLint/TS-Rules.
- Allowing `*` with `Allow-Credentials` → blocked by browsers; lint rule disallows this.
- Mis-ordering middleware (logging before CORS) can strip headers—keep CORS first.
Directory Convention
- /middleware/cors.ts
- /config/cors-allowed-origins.json
- /tests/cors/