0x55aa
← Back to Blog

CORS: The Security Feature Everyone Hates (Until They Get Hacked) 🌐

8 min read

CORS: The Security Feature Everyone Hates (Until They Get Hacked) 🌐

So you're building an API and suddenly your browser throws: "Access blocked by CORS policy"

Your first thought? "CORS is broken, let me Google how to disable it" 🤦‍♂️

Stop right there! You're about to create a security hole big enough to drive a truck through. Let me save you from yourself.

What Even Is CORS? (The 2-Minute Explanation) 🤔

CORS = Cross-Origin Resource Sharing

Translation: Rules that control which websites can talk to your API.

Think of it like a nightclub:

  • Without CORS: Random websites walk in and steal your API data
  • With CORS: Bouncer checks IDs - "You're on the list? Cool. You're not? Get out."

The key point: CORS is a browser security feature, not a bug. Your browser is trying to protect your users!

Why Browsers Block Cross-Origin Requests 🚫

The scenario without CORS:

  1. You're logged into yourbank.com
  2. You visit evil-site.com
  3. Evil site's JavaScript makes a request to yourbank.com/api/transfer-money
  4. Your browser automatically includes your bank cookies
  5. Money goes bye-bye 💸

With CORS: Browser says "Wait, does yourbank.com allow evil-site.com to make requests? No? BLOCKED."

Real Talk: CORS prevents websites from silently stealing data from APIs where you're authenticated. It's literally protecting you!

The Mistakes That Get You Hacked 💣

Mistake #1: The Nuclear Option (Access-Control-Allow-Origin: *)

// The "fix" every Stack Overflow answer suggests
app.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*');  // DANGER!
    res.header('Access-Control-Allow-Methods', '*');  // STOP!
    res.header('Access-Control-Allow-Headers', '*');  // WHY!
    next();
});

What this means: "Every website in the entire universe can access my API!" 🌍

The problem:

  • Evil sites can now call your API
  • If your API returns user data, it's now public
  • Credentials won't work with * (but you're already screwed if you're using this)

When * is actually okay:

  • Truly public APIs (weather data, public blog posts, etc.)
  • No authentication required
  • No user-specific data

Translation: If your API requires login or returns personalized data, * is a terrible idea.

Mistake #2: Reflecting the Origin (The "Smart" Bad Idea)

// "I'm clever!" (Narrator: They were not)
app.use((req, res, next) => {
    const origin = req.headers.origin;
    res.header('Access-Control-Allow-Origin', origin);  // Just as bad!
    res.header('Access-Control-Allow-Credentials', 'true');
    next();
});

What you think: "Only the requesting origin can access it!"

What actually happens: You just allowed EVERY origin. Evil sites included!

Why it's dangerous:

  • Access-Control-Allow-Credentials: true means cookies are included
  • Evil site makes request → Gets reflected as allowed → Steals user data
  • Congrats, you just reinvented the vulnerability CORS was designed to prevent

Mistake #3: Not Validating Origins Properly

// BAD: Substring matching
const origin = req.headers.origin;
if (origin && origin.includes('mysite.com')) {
    res.header('Access-Control-Allow-Origin', origin);
}

The exploit: Attacker registers mysite.com.evil-hacker.ru → Passes your check! 🎭

Or: evil-mysite.com, mysite.com.attacker.xyz, etc.

Real Talk: Origin validation is like comparing passwords - close enough doesn't count!

The Right Way to Handle CORS ✅

Option 1: Whitelist Specific Origins (The Safe Default)

// Node.js with Express
const allowedOrigins = [
    'https://myapp.com',
    'https://www.myapp.com',
    'https://admin.myapp.com',
    'http://localhost:3000'  // For development
];

app.use((req, res, next) => {
    const origin = req.headers.origin;

    // EXACT match only!
    if (allowedOrigins.includes(origin)) {
        res.header('Access-Control-Allow-Origin', origin);
        res.header('Access-Control-Allow-Credentials', 'true');
        res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
        res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    }

    // Handle preflight requests
    if (req.method === 'OPTIONS') {
        return res.sendStatus(204);
    }

    next();
});

Why this works:

  • Exact match only - no substring tricks
  • Only trusted origins get access
  • Credentials (cookies) work safely
  • Clear list of who's allowed

Pro Tip: Use environment variables for different environments!

const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];

Option 2: Using the cors Package (Even Better)

const cors = require('cors');

const corsOptions = {
    origin: function (origin, callback) {
        const allowedOrigins = [
            'https://myapp.com',
            'https://www.myapp.com'
        ];

        // Allow requests with no origin (mobile apps, Postman)
        if (!origin) return callback(null, true);

        if (allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            callback(new Error('Not allowed by CORS'));
        }
    },
    credentials: true,  // Allow cookies
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400  // Cache preflight for 24 hours
};

app.use(cors(corsOptions));

Benefits:

  • Battle-tested library
  • Handles preflight automatically
  • Clear error messages
  • Less code to screw up

Option 3: Different Policies for Different Endpoints

// Public endpoints: Allow everyone
app.get('/api/public/posts',
    cors({ origin: '*' }),
    getPosts
);

// Protected endpoints: Strict whitelist
const restrictedCors = cors({
    origin: ['https://myapp.com', 'https://www.myapp.com'],
    credentials: true
});

app.get('/api/user/profile',
    restrictedCors,
    authenticate,  // Require auth
    getProfile
);

app.post('/api/user/update',
    restrictedCors,
    authenticate,
    updateProfile
);

Translation: Public stuff gets *, user data gets locked down!

Understanding Preflight Requests 🛫

The weirdness: Browser sends an OPTIONS request before your actual request.

Why it happens: For "complex" requests (anything beyond simple GET/POST with basic headers).

What the browser is asking: "Hey API, is myapp.com allowed to make a POST with custom headers?"

Your API's job: Respond with proper CORS headers.

// Preflight response
app.options('/api/*', (req, res) => {
    res.header('Access-Control-Allow-Origin', 'https://myapp.com');
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.header('Access-Control-Max-Age', '86400');  // Cache for 24h
    res.sendStatus(204);  // No content
});

Performance tip: Use Access-Control-Max-Age to cache preflight responses. No need to check every single time!

The Development vs Production Dance 💃

The problem: You need localhost for dev, but production should be locked down.

The solution:

const allowedOrigins = process.env.NODE_ENV === 'production'
    ? [
        'https://myapp.com',
        'https://www.myapp.com'
      ]
    : [
        'http://localhost:3000',
        'http://localhost:3001',
        'https://myapp.com'  // Still test prod config
      ];

Or use wildcards for local only:

origin: function (origin, callback) {
    // Production: exact match
    if (process.env.NODE_ENV === 'production') {
        const allowed = ['https://myapp.com'];
        return callback(null, allowed.includes(origin));
    }

    // Development: allow localhost on any port
    if (origin?.match(/^http:\/\/localhost:\d+$/)) {
        return callback(null, true);
    }

    callback(new Error('Not allowed by CORS'));
}

Best of both worlds: Easy local development, secure production!

Common CORS Scenarios (And How to Fix Them) 🔧

Scenario 1: "CORS error but Postman works fine!"

Why: Postman doesn't enforce CORS (it's not a browser).

The fix: Configure CORS properly for browsers, not for Postman.

Scenario 2: "Working in dev, broken in production!"

Why: You allowed http://localhost but your prod site is https://myapp.com.

The fix: Use environment-specific origin lists (see above).

Scenario 3: "GET works, POST doesn't!"

Why: POST triggers preflight, your OPTIONS handler is missing.

The fix: Add OPTIONS handler (or use the cors package).

Scenario 4: "CORS works but cookies don't send!"

Why: Need both Access-Control-Allow-Credentials: true AND credentials: 'include' in fetch.

The fix:

// Backend
res.header('Access-Control-Allow-Credentials', 'true');

// Frontend
fetch('https://api.myapp.com/data', {
    credentials: 'include',  // Send cookies!
    headers: { 'Content-Type': 'application/json' }
});

The Security Checklist 📋

Before you deploy:

  • NOT using Access-Control-Allow-Origin: * for protected endpoints
  • Using exact origin matching (not substring)
  • Only whitelisting domains you actually own/trust
  • Access-Control-Allow-Credentials only with specific origins
  • Handling OPTIONS preflight requests
  • Different CORS policies for public vs protected endpoints
  • Environment-specific origin lists
  • Caching preflight with Access-Control-Max-Age
  • Testing with actual browsers (not just Postman!)

Quick Wins (Fix Your CORS Today!) 🏃‍♂️

Node.js/Express:

npm install cors

Laravel:

php artisan config:publish cors
# Edit config/cors.php

Django:

pip install django-cors-headers
# Add to MIDDLEWARE

FastAPI:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://myapp.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Real Talk 💬

Q: "Can I just disable CORS?"

A: No. It's a browser feature, not something you can disable server-side. You can misconfigure it to allow everything (which is basically the same as disabling it and equally stupid).

Q: "Why does my API work in Postman but not the browser?"

A: Postman isn't a browser, so CORS doesn't apply. This is by design!

Q: "What about mobile apps?"

A: Mobile apps don't enforce CORS (it's browser-specific). But you should still secure your API with proper authentication!

Q: "Is CORS authentication?"

A: NO! CORS controls which origins can access your API. You still need proper authentication (JWT, sessions, etc.) to control who can access it!

The Bottom Line

CORS exists for a reason: to protect your users from malicious websites stealing their data.

When you see a CORS error, don't think "how do I disable this annoying thing?" Think "how do I configure this security feature properly?"

The essentials:

  1. Whitelist specific origins (exact matches only!)
  2. Never reflect the Origin header without validation
  3. Use * only for truly public APIs (no auth, no user data)
  4. Handle preflight OPTIONS requests properly
  5. Test with actual browsers, not just Postman

Think of CORS like a seatbelt - annoying when you first get in the car, but you'll be really glad it's there when you need it! 🚗💨


Still confused about CORS? Hit me up on LinkedIn - I've debugged enough CORS issues to write a book!

Want more security content? Check out my GitHub and follow this blog!

P.S. - If you're reading this and you have Access-Control-Allow-Origin: * with credentials enabled, go fix that RIGHT NOW. I'll wait. 🌐✨