BFF in ASP.NET Core #6 - Securing our BFF with CORS

BFF in ASP.NET Core #6 – Securing our BFF with CORS

In this post, we take the next step in securing our Backend-for-Frontend (BFF) by adding robust Cross-Origin Resource Sharing (CORS) protection. CORS is essential for defending against a range of cross-origin attacks, and implementing it correctly is crucial for any application that handles sensitive data.

We’ll explore the types of attacks that CORS helps prevent, walk through its practical implementation in ASP.NET Core, and explain why proper configuration matters.

This is a big topic, so I’ve split it into multiple parts. You can jump to the section you need, but for background and context, it’s best to start here:

Part 1 – Introduction
Part 2 – Introducing the Backend-for-Frontend (BFF) pattern
Part 3 – Securing the Cookie Session
Part 4 – Implementing a BFF in ASP.NET Core
Part 5 – Automatic Token Renewal
Part 6 – Securing the BFF using CORS
Part 7 – Introducing the Duende BFF Library (coming soon)

The Attack Scenario: When Your Site Has an Evil Twin

Let’s set the stage:

Our real site is localtest.me. This is the domain we want to protect. But attackers are always looking for new tricks. Imagine someone copies your site and hosts it on a different domain, like yoggle.com. Or, even worse, they manage to run a version on a subdomain, like hacked.localtest.me, after a subdomain takeover attack.

The Attack Scenario: When Your Site Has an Evil Twin - CORS

The attacker’s goal is simple. By tweaking the JavaScript on these fake sites, they try to send requests to your real BFF component at localtest.me. With the credentials flag set in their fetch calls, they attempt to include any cookies your users might have for your site:

				
					fetch('https://localtest.me:5001/api/local', {
    method: 'GET',
    credentials: 'include',
})

				
			

Why use localtest.me and yoggle.com?

Both domains resolve to 127.0.0.1, making them perfect for local development and testing. Using realistic domain names helps us reason about cross-origin behavior more accurately compared to using just localhost. Tools like mkcert let us easily create trusted certificates for HTTPS, taking our setup even closer to production.

Why not use localhost?

Browsers treat localhost as “potentially trustworthy” according to the Secure Contexts specification. This means features that normally require HTTPS on regular domains may still work over plain HTTP on localhost. While this is convenient for development, it can hide security issues that will only appear in production. For more accurate security testing, it is better to use domains like localtest.me that behave more like real-world environments.

Preventing Unauthorized Cross-Origin API Requests

Our current Backend-for-Frontend (BFF) setup already applies strong protections to the session cookie:

  • HttpOnly
  • SameSite=Strict or Lax
  • A prefixed cookie name (__Host-)

The __Host- prefix is especially valuable. It enforces that the cookie can only be set from the root path (/), that it must be sent over HTTPS, and it cannot include a Domain attribute. This prevents the cookie from being shared with subdomains, even if one is compromised.

However, one risk still remains: a user could be tricked into visiting a fake version of your site, perhaps through a phishing email or a malicious link. That fake site could then attempt to send requests to your real BFF using the user’s browser. If the target origin matches and your CORS settings are too permissive, the browser may include the session cookie in these requests.

To better demonstrate this risk, let’s temporarily set the SameSite value to None in our cookie configuration:

				
					.AddCookie("cookie", options =>
{
    options.Cookie.Name = "__Host-AuthCookie";
    options.Cookie.SameSite = SameSiteMode.None;
    ...
});

				
			

We set SameSite to None here so that browser SameSite restrictions do not interfere with our demonstration.

Exploring the Cross-Origin Request Problem

The attacker has deployed two copies of our site: one on a different domain (yoogle.com) and one on a subdomain (hacked.localtest.me). These malicious sites attempt to send requests to our legitimate BFF component on localtest.me, and if cookies are present in the browser, they may be included in the request.

Exploring the Cross-Origin Request Problem

At first glance, everything appears secure. By default, the browser blocks the response due to missing CORS headers. In the browser console, we see an error like this:

CORS Error - Access to fetch at from origin has been blocked by CORS policy.

And in the application, we observe a failed request:

Failed to fetch error message.

This looks reassuring, right? The request failed, so we must be protected!

But here’s the critical issue: we’re not actually safe.

Confused about CORS?

My Web Security Fundamentals workshop covers the complete spectrum of web vulnerabilities and defenses, from XSS and CSRF to secure authentication patterns and coding practices.

The Problem with Simple CORS Requests

When JavaScript makes a cross-origin request, the browser applies CORS rules to decide whether the response should be passed back to the page.

By enabling CORS with strict settings, we can control:

  • Which origins are allowed to call our API
  • Which HTTP methods and headers can be used

This helps prevent other sites from making unauthorized API requests using JavaScript.

We might think this keeps us safe! After all, the application shows an error, and the request “failed,” right?

But if you inspect the traffic using a tool like Fiddler, you’ll see that the request was still sent to our BFF:

Sample requests in Fiddler

So what’s going on?

To understand this, we need to stop thinking of ‘the page’ as one thing. The web page and the browser that displays it are actually separate components working together, like this:

With Cross-Origin Resource Sharing (CORS), we need to separate the page from the browser.

When JavaScript on a page makes a request, it doesn’t directly contact the server. Instead, it asks the browser to make the request on its behalf.

How CORS prevents the result to reach the page.

In our case, the backend did not return the expected CORS headers, so the browser blocks the response from being delivered to the page. But the request still reaches the server, and it includes your cookies.

That means the backend might still process the action, even if the page does not receive the result.

So yes, the request “failed” in the browser, but not on the network. That is the real problem.

What is the solution?

Preflight CORS Requests

In the previous example, the browser still sent the real request, even though the page was blocked from seeing the response. This can be a problem if the request changes state on the server.

To prevent the browser from sending the actual request right away, we can make it ask for permission first. This is done using a preflight request. The browser first sends a separate OPTIONS request to check if the real request is allowed.

How preflight requests in Cross-Origin Resource Sharing (CORS) work.

In some cases, the browser will do this automatically based on the method or headers in the request. But if we want to force the browser to always perform a preflight, we can add a custom header:

				
					fetch('https://localtest.me:5001/api/remote', {
    method: 'GET',
    credentials: 'include',
    headers: {
        'X-CSRF': '1'
    }

				
			

The X-CSRF header can be any custom name. The important part is that it makes the request a non-simple request (one that requires special CORS handling), which causes the browser to run a preflight check before sending it.

A preflight request looks like this:

				
					OPTIONS https://localtest.me:5001/api/local HTTP/1.1
Host: localtest.me:5001
Access-Control-Request-Method: GET
Access-Control-Request-Headers: x-csrf
Origin: https://hacked.localtest.me:7001
Referer: https://hacked.localtest.me:7001/

				
			

If yoogle.com is a trusted domain, then the server should respond to the browser’s preflight request with the following headers to allow the actual request:

				
					HTTP/1.1 204 No Content
Date: Wed, 25 Jun 2025 13:06:44 GMT
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: X-CSRF,Content-Type
Access-Control-Allow-Methods: GET
Access-Control-Allow-Origin: https://yoogle.com:6001
Access-Control-Max-Age: 600

				
			

This response tells the browser that requests from https://yoogle.com:6001 are allowed, including credentials and the specified headers and methods. The Access-Control-Max-Age header allows the result to be cached for 10 minutes.

Important

Preflight requests only happen for cross-origin requests. For same-origin requests, the browser skips this step and sends the request directly.

Enabling CORS in our BFF

To allow the backend to respond to the browser’s CORS preflight requests, we need to enable and configure CORS in our application.

Start by adding this configuration to your Program.cs file:

				
					builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins("https://localtest.me:5001")
              .AllowAnyMethod()
              .WithHeaders("X-CSRF", "Content-Type")
              .AllowCredentials()
              // Optionally cache for 10 minutes
              .SetPreflightMaxAge(TimeSpan.FromMinutes(10));
    });
});

				
			

Then we add the CORS middleware to the request pipeline:

				
					...
app.UseRouting();

app.UseCors();

app.UseAuthentication();
app.UseAuthorization();
...

				
			

Order matters here. The CORS middleware must be placed after UseRouting() but before UseAuthentication() and UseAuthorization() to work correctly.

With this configuration in place, our BFF now has proper CORS protection. The browser will only allow cross-origin requests from trusted origins, and only when they follow the rules we have defined.

Improving the CORS Protection

Adding the X-CSRF header in frontend requests forces the browser to send a preflight request. But what if an attacker simply skips that header? In that case, the browser might treat the request as a simple request and send it directly, without a preflight.

To handle this vulnerability, we need to ensure that the backend enforces the presence of the X-CSRF header. If the header is missing or invalid, then the request gets rejected before any processing occurs.

We can implement this protection using a custom middleware component:

				
					public class CsrfHeaderMiddleware
{
    private readonly RequestDelegate _next;

    public CsrfHeaderMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var path = context.Request.Path.Value?.ToLowerInvariant();

        // Check if request is for protected endpoints
        if (ShouldCheckCsrfHeader(path))
        {
            var csrfHeader = context.Request.Headers["X-CSRF"]
                                            .FirstOrDefault();

            if (csrfHeader != "1")
            {
                context.Response.StatusCode = 401;
                await context.Response.WriteAsync(
                              "Missing or invalid X-CSRF header");
                return;
            }
        }

        await _next(context);
    }

    private static bool ShouldCheckCsrfHeader(string? path)
    {
        if (string.IsNullOrEmpty(path))
            return false;

        return path.StartsWith("/bff/session") || path.StartsWith("/api/");
    }
}

				
			

To make it easier to add to the pipeline, we also define this extension method:

				
					public static class CsrfHeaderMiddlewareExtensions
{
    public static IApplicationBuilder CheckForCsrfHeader(
                              this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<CsrfHeaderMiddleware>();
    }
}

				
			

Then, in your application setup, add it after the CORS middleware:

				
					app.UseCors();
app.CheckForCsrfHeader();
				
			

This middleware answers a critical question:

If this header is missing, is it really coming from one of our legitimate clients?

It provides the backend with a simple yet effective way to block requests that bypass the expected request flow. This protection also guards against simple HTML forms posting directly to your application, since forms cannot set custom headers.

Removing the CORS Credentials Option

If your frontend only needs to communicate with your own BFF (same-origin requests), consider removing the credentials: ‘include’ option from your fetch calls altogether.

Why does this matter?

By default, browsers automatically include cookies in same-origin requests. However, without the credentials option, cookies will never be sent on cross-origin requests, even if the backend is misconfigured to allow them.

This creates an additional layer of defense-in-depth. If a cross-origin request is made accidentally or maliciously, it will be unauthenticated, and your backend will most likely reject it.

It’s a simple yet effective way to reduce the risk of cross-origin data leakage and provides protection against configuration mistakes.

				
					fetch('https://localtest.me:5001/api/remote', {
    method: 'GET',
    headers: {
        'X-CSRF': '1'
    }

				
			

What’s next?

In this post, we secured our BFF with robust CORS protection and custom middleware to defend against cross-origin attacks. In the next and final post of this series, we’ll replace our custom BFF implementation with Duende.BFF.

(coming soon)

More Blog Posts by the Author

Share This Story

About The Author

Hi, I’m Tore! I have been fascinated by computers since I unpacked my first Commodore VIC-20. I am a Microsoft MVP in .NET and  I provide freelance application development, developer training and coaching services, focusing on ASP.NET Core, IdentityServer, OpenID Connect, Architecture, and Web Security. Let’s connect on LinkedIn and Twitter!

Related Posts

Do You Want Tore To Be Your Mentor?

Services 🚀

I offer training and coaching for professional developers and consulting services for startups and enterprises. Find out more on my business website. 

Tore’s Newsletter

Be the First to Know! Get notified about my latest blog posts, upcoming presentations, webinars, and more — subscribe today!

Cartoon of Tore Nestenius

About me

Hi! I’m Tore Nestenius. I’m a trainer and senior software developer focusing on Architecture, Security & Identity, .NET, C#, Backend, the Cloud, and more.

Do You Want Tore To Be Your Mentor?

Services 🚀

I offer training and coaching for professional developers and consulting services for startups and enterprises. Find out more on my business website. 

Blog Categories