REST API Security · Lesson 17 of 30
CORS Explained for REST APIs
"Blocked by CORS policy" is the error every frontend developer meets sooner or later. CORS (Cross-Origin Resource Sharing) isn't a bug or an API security feature – it's the browser relaxing its same-origin policy when your API explicitly says a website may read its responses. Once you know which headers the browser checks, CORS errors take minutes to fix.
In this lesson
- Origins and the same-origin policy
- Simple requests vs preflight
- Configuring CORS in Express – safely
- Credentials and common mistakes
Origins
An origin is scheme + host + port: https://app.learnfrenzy.com and https://api.learnfrenzy.com are different origins, and so are http://localhost:5173 and http://localhost:3000. By default, JavaScript on one origin may send requests to another, but the browser won't let it read the response unless the server opts in with CORS headers.
CORS only applies in browsers
curl, Postman, servers and mobile apps ignore CORS completely. That's why "it works in Postman but not in the browser" is the classic symptom – and why CORS is not a way to protect your API. Authentication and authorisation do that.
Simple requests and preflight
A preflighted request
- Preflightthe browser sends OPTIONS with Origin, Access-Control-Request-Method and -Headers
- Permissionthe API answers with Access-Control-Allow-Origin, -Methods, -Headers (and Max-Age)
- Real requestonly if allowed: the PUT/DELETE/JSON POST is sent
- Readthe response also needs Access-Control-Allow-Origin, or JavaScript can't read it
GET, HEAD and form-style POSTs with simple headers skip the preflight. Anything else – PUT, DELETE, Content-Type: application/json, an Authorization header – triggers one.
CORS in Express
// server.js
import express from 'express';
import cors from 'cors';
const app = express();
app.use(express.json());
const ALLOWED = ['https://app.learnfrenzy.com', 'http://localhost:5173'];
app.use(cors({
origin: (origin, done) => done(null, !origin || ALLOWED.includes(origin)), // allow-list, not '*'
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // allow cookies / auth
maxAge: 600, // cache preflight for 10 minutes
}));
app.put('/api/profile', (req, res) => res.json({ saved: true, name: req.body.name }));
app.listen(process.env.PORT ?? 3000);
$ curl -X OPTIONS http://localhost:3000/api/profile -H 'Origin: https://app.learnfrenzy.com' -H 'Access-Control-Request-Method: PUT' -H 'Access-Control-Request-Headers: content-type,authorization'
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.learnfrenzy.com
Vary: Origin
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 600
$ curl -X PUT http://localhost:3000/api/profile -H 'Origin: https://app.learnfrenzy.com' -H 'Content-Type: application/json' -d '{"name":"Asha"}'
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.learnfrenzy.com
Vary: Origin
Access-Control-Allow-Credentials: true
Content-Type: application/json; charset=utf-8
{
"saved": true,
"name": "Asha"
}
$ curl -X OPTIONS http://localhost:3000/api/profile -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: PUT'
HTTP/1.1 200 OK
Allow: PUT
Content-Type: text/plain
PUT- The allowed origin's preflight got
204with the allowed methods and headers; the real request echoed the exact origin. - The unknown origin's preflight got Express's default OPTIONS reply but no
Access-Control-Allow-Originheader, so the browser refuses to send the real PUT at all. - Careful: for simple requests (a plain GET or form POST) there is no preflight – the server runs the request and the browser only hides the response from the page. CORS limits what pages can read; it never replaces authentication and CSRF protection.
Vary: Origintells caches that the response depends on the origin.
Common mistakes
| Mistake | Fix |
|---|---|
Access-Control-Allow-Origin: * with credentials | browsers reject it – echo specific allowed origins |
| Reflecting any Origin header back | check against an allow-list |
| No OPTIONS handling (auth middleware rejects the preflight) | run CORS middleware before authentication |
Forgetting a custom header in allowedHeaders | list every header your frontend sends |
| Fixing CORS in the frontend | CORS is configured on the server (or avoided with a same-origin proxy) |
Practice
Try it yourself
A React app on
http://localhost:5173gets a CORS error callingDELETE /api/tasks/1with anAuthorizationheader. What do you check?Show answer
That the API answers the OPTIONS preflight (CORS middleware before auth), that
http://localhost:5173is in the allowed origins, thatDELETEis in the allowed methods andAuthorizationin the allowed headers – and that the DELETE response itself includesAccess-Control-Allow-Origin. In development, a Vite proxy can also avoid cross-origin calls entirely.
Quick check
Does CORS protect your API from curl?
No – only browsers enforce CORS.
What triggers a preflight?
Non-simple methods (PUT, DELETE…), JSON Content-Type, or custom headers like Authorization.
Can you use * with credentials?
No – return a specific origin.
Summary
- CORS lets a server allow specific browser origins to read its responses.
- Non-simple requests send an OPTIONS preflight first; answer it before authentication.
- Use an origin allow-list, list methods and headers, and never combine * with credentials.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.