Learn Security Headers and CSP Using Azure Static Web Apps

Oct 2, 2026SecurityAzureEngineering
Summarize with AI: Google AI Claude ChatGPT Perplexity Grok

AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.

Share:

Introduction

This article demonstrates how to configure security headers and a Content Security Policy for an Azure Static Web App, using one file at the root of your repository.

There is no web.config, no nginx.conf and no middleware. Everything is in staticwebapp.config.json.

Security Headers

A security header is an instruction from your server to the browser. "Do not put my site in a frame." "Do not guess the type of this file." The browser enforces it, so a header can stop an attack that your application code cannot see.

Features

  • One file, no build step, no code.
  • Applies to every response, including your static files.
  • Free — no performance cost.

With the following steps, you can configure your own.

  1. Add the simple global headers.
  2. Write the Content Security Policy.
  3. Widen the policy for each third-party service.
  4. Handle the API route and the SPA fallback.

Add the Simple Global Headers

These go in globalHeaders.

"globalHeaders": {
  "X-Content-Type-Options": "nosniff",
  "X-Frame-Options": "DENY",
  "X-XSS-Protection": "1; mode=block",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()"
}

What each one does.

X-Content-Type-Options: nosniff stops the browser from guessing a file's type from its content. Without it, a file you serve as plain text can be run as JavaScript if the browser decides it looks like JavaScript.

X-Frame-Options: DENY stops your site being loaded in an iframe on someone else's page. That is clickjacking — your real page, invisible, under their fake buttons. If you need to allow framing by your own domain, use SAMEORIGIN instead.

Referrer-Policy: strict-origin-when-cross-origin sends the full URL as the referrer inside your own site, but only the domain when the visitor leaves. So you keep your internal analytics without telling other sites which of your pages someone was reading.

Permissions-Policy turns off browser features you never use. We do not want the camera, microphone or location, so nothing on our pages — including anything embedded — can ask for them.

X-XSS-Protection is the old one. Modern browsers ignore it and a real CSP replaces it. It is harmless, and it still does something in older browsers, so we keep it.

Write the Content Security Policy

CSP is the big one. It lists where the browser is allowed to load each type of resource from, and blocks everything else.

Start from the most useful line.

default-src 'self';

That means: for anything I do not mention specifically, only allow my own domain. Everything after it is an exception you are making on purpose.

Here is ours, split up for reading.

default-src 'self';
script-src  'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com;
style-src   'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src    'self' https://fonts.gstatic.com;
img-src     'self' data: https:;
connect-src 'self' https://*.azure.com https://challenges.cloudflare.com
            https://www.google-analytics.com https://analytics.google.com
            https://region1.google-analytics.com;
frame-src   https://challenges.cloudflare.com

Read it as a list of decisions, because that is what it is. Every entry is a third-party service we added, and each one needed exactly the directives it uses and no more.

Widen the Policy for Each Third-Party Service

This is the part people find confusing. A service is never "one line" in a CSP — it needs a directive for each kind of thing it loads.

Cloudflare Turnstile needed three. script-src to load its script, frame-src because the widget renders inside an iframe, and connect-src because it calls home to verify. Miss any one and the widget fails, usually with nothing helpful in the console.

Google Fonts needed two, and they are two different domains. The stylesheet comes from fonts.googleapis.com, so that goes in style-src. The actual font files come from fonts.gstatic.com, so that goes in font-src. Adding only the first gives you a page that loads the CSS and then silently shows fallback fonts.

Google Analytics needed script-src for the tag manager and then three hosts in connect-src, including the regional region1.google-analytics.com. That regional host is the one everybody misses, because it only appears for some visitors depending on where they are.

My suggestion: open the browser console after adding any third-party script. CSP violations are reported there with the exact directive that blocked them. Do not guess — read the error, add that one host to that one directive, and check again.

A CSP violation reported in the browser console

Two entries are weaker than the rest, and I would rather say so than pretend.

'unsafe-inline' in script-src. This allows inline <script> blocks, which removes a good part of what CSP protects you from. We need it because the theme is applied by a small inline script before paint, to avoid a flash of the wrong theme. The proper fix is a nonce or a hash for that one script. It is on the list.

img-src https: allows an image from any HTTPS host. That is broad. It is there because blog posts imported from WordPress still reference images on other domains. Narrowing it means fixing those posts first.

Both are real trade-offs, not oversights. Write yours down the same way, so the next person knows which lines are deliberate.

Handle the API Route and the SPA Fallback

Two more pieces in the same file.

"routes": [
  { "route": "/api/*", "allowedRoles": ["anonymous"] },
  { "route": "/sitemap.xml", "rewrite": "/api/sitemap" },
  { "route": "/*", "allowedRoles": ["anonymous"] }
]

allowedRoles: ["anonymous"] on /api/* means Static Web Apps does not require its own built-in authentication. That is correct here because our API does its own auth check inside each function. Anonymous at this layer does not mean unprotected — it means the platform steps out of the way and your code decides.

The sitemap.xml line rewrites a normal-looking URL onto an API function, so the sitemap can be generated rather than built as a file.

"navigationFallback": {
  "rewrite": "/index.html",
  "exclude": ["/api/*", "/assets/*", "*.{css,js,ico,png,jpg,jpeg,svg,webp,woff,woff2,webmanifest,json}"]
}

This is the Angular routing fallback — unknown paths serve index.html so the app can route them. The exclude list is the important half. Without it a missing image returns your HTML page with a 200 status, and you spend an afternoon wondering why a broken icon "loads successfully".

Check Your Work

After deploying, test the live site.

curl -sI https://your-site.com | grep -iE "content-security|x-frame|x-content|referrer|permissions"

Security headers returned by the deployed site

Then run it through securityheaders.com for a second opinion.

Conclusion

In this article we learned how to set global security headers and a Content Security Policy in staticwebapp.config.json, how to widen the policy correctly for each third-party service, and why the navigationFallback exclude list matters.

Start with default-src 'self' and add exceptions one at a time, reading the console each time. A policy built that way stays tight. A policy written all at once usually ends up with a wildcard somebody added to make an error go away.

Reference

#csp#security-headers#azure-static-web-apps#angular#configuration

Comments

Be the first to comment.

Leave a comment

Never shown publicly.

Comments are reviewed before appearing publicly.