June 11, 2026

Running Umbraco behind Cloudflare or a reverse proxy

How forwarded headers, public hosts, canonical URLs, and authentication callbacks interact when Umbraco runs behind Cloudflare or another proxy.

Running Umbraco directly on the public internet is not the only deployment model.

Very often the request path looks more like this:

Browser -> Cloudflare -> reverse proxy / load balancer -> ASP.NET Core -> Umbraco

That architecture is normal. It gives you TLS termination, caching, WAF rules, request filtering, better logging and a cleaner way to route traffic.

But it also creates a small problem with a large blast radius:

The Umbraco application may not see the same URL the visitor used.

The browser requested:

https://www.example.com/umbraco

But the ASP.NET Core app might receive:

http://internal-container:8080/umbraco

If the app believes the internal URL is the real URL, things start to break in subtle ways:

  • OpenID Connect callback URLs can be generated with http instead of https;
  • Auth0 or another identity provider can reject the redirect URI;
  • canonical URLs can use the wrong scheme or host;
  • Open Graph URLs can point to the internal host;
  • sitemap entries can be wrong;
  • redirects can bounce between HTTP and HTTPS;
  • Umbraco backoffice links can behave strangely in some setups.

The fix is not Umbraco-specific. It is an ASP.NET Core reverse proxy concern: configure forwarded headers early, and be explicit about which headers you trust.

The examples below are based on a real Umbraco 17 project running behind Cloudflare/reverse proxy infrastructure, but they are simplified for publication.

What the proxy knows and the app does not

When a proxy forwards a request to ASP.NET Core, it can preserve the original request information with headers such as:

X-Forwarded-For
X-Forwarded-Proto
X-Forwarded-Host

ASP.NET Core can use those headers to update:

  • HttpContext.Connection.RemoteIpAddress
  • HttpContext.Request.Scheme
  • HttpContext.Request.Host

Microsoft documents this behavior in the Forwarded Headers Middleware documentation:

https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer

Cloudflare also documents the request headers it sends to origins, including X-Forwarded-For, X-Forwarded-Proto, CF-Connecting-IP and CF-Visitor:

https://developers.cloudflare.com/fundamentals/reference/http-headers/

The key point is this: forwarded headers do nothing unless ASP.NET Core is configured to process them.

Outside specific IIS hosting scenarios, the forwarded headers middleware is not something you can assume is already doing the right thing.

A small configuration object

I like keeping proxy behavior configurable.

In this project I use a small options class:

public class ProxyForwardingOptions
{
    public const string SectionName = "ProxyForwarding";

    public bool TrustAllForwardedHeaders { get; set; } = true;

    public int? ForwardLimit { get; set; }

    public bool ApplyCloudflareVisitorScheme { get; set; } = true;
}

And bind it from configuration:

builder.Services
    .AddOptions<ProxyForwardingOptions>()
    .Bind(builder.Configuration.GetSection(ProxyForwardingOptions.SectionName));

The matching configuration looks like this:

{
  "ProxyForwarding": {
    "TrustAllForwardedHeaders": true,
    "ForwardLimit": null,
    "ApplyCloudflareVisitorScheme": true
  }
}

Those defaults are convenient for a controlled deployment where the origin is not directly exposed and only the trusted proxy can reach it.

They are not universally safe.

If your app can receive public traffic directly, do not blindly trust all forwarded headers. A client can spoof them. In that case, configure KnownProxies, KnownIPNetworks, AllowedHosts and ForwardLimit according to your infrastructure.

Configuring forwarded headers

The project configures ForwardedHeadersOptions during startup:

var proxyOptions = builder.Configuration
    .GetSection(ProxyForwardingOptions.SectionName)
    .Get<ProxyForwardingOptions>() ?? new ProxyForwardingOptions();

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor |
        ForwardedHeaders.XForwardedProto |
        ForwardedHeaders.XForwardedHost;

    options.RequireHeaderSymmetry = false;

    if (proxyOptions.TrustAllForwardedHeaders)
    {
        options.KnownProxies.Clear();
        options.KnownIPNetworks.Clear();
    }

    options.ForwardLimit = proxyOptions.ForwardLimit;
});

The three forwarded headers matter for different reasons.

X-Forwarded-For helps restore the original client IP.

X-Forwarded-Proto helps ASP.NET Core understand that the original request was https, even if the proxy talks to the app over plain HTTP.

X-Forwarded-Host helps restore the public host, which matters for absolute URLs and redirects.

For Umbraco, the last two are especially important because many features eventually touch Request.Scheme, Request.Host or Request.GetDisplayUrl().

Middleware order

The middleware must run early.

In this project, the infrastructure setup is called before the Umbraco pipeline:

var app = builder.Build();

app.UseProjectInfrastructure();

await app.BootUmbracoAsync();

app.AddProjectMiddleware();

app.UseUmbraco()
    .WithMiddleware(u =>
    {
        u.UseBackOffice();
        u.UseWebsite();
    })
    .WithEndpoints(u =>
    {
        u.UseBackOfficeEndpoints();
        u.UseWebsiteEndpoints();
    });

And UseProjectInfrastructure starts with:

public static WebApplication UseProjectInfrastructure(this WebApplication app)
{
    var proxyOptions = app.Services.GetRequiredService<IOptions<ProxyForwardingOptions>>().Value;

    app.UseForwardedHeaders();

    app.Use((context, next) =>
    {
        if (proxyOptions.ApplyCloudflareVisitorScheme &&
            !context.Request.IsHttps &&
            context.Request.Headers.TryGetValue("CF-Visitor", out var visitor) &&
            visitor.ToString().Contains("\"scheme\":\"https\"", StringComparison.OrdinalIgnoreCase))
        {
            context.Request.Scheme = "https";
        }

        return next(context);
    });

    return app;
}

This ordering is the important part.

Anything that builds URLs later in the pipeline should see the corrected request scheme and host.

That includes:

  • Umbraco backoffice authentication;
  • OpenID Connect redirects;
  • canonical URLs;
  • og:url;
  • sitemap generation;
  • JSON-LD @id values;
  • custom redirects.

Why the extra Cloudflare CF-Visitor check?

Cloudflare sends X-Forwarded-Proto, and in most setups that should be enough.

But Cloudflare also sends CF-Visitor, which contains a JSON object with a scheme value. Cloudflare documents it like this:

CF-Visitor: { "scheme": "https" }

In this project I keep a defensive fallback:

if (proxyOptions.ApplyCloudflareVisitorScheme &&
    !context.Request.IsHttps &&
    context.Request.Headers.TryGetValue("CF-Visitor", out var visitor) &&
    visitor.ToString().Contains("\"scheme\":\"https\"", StringComparison.OrdinalIgnoreCase))
{
    context.Request.Scheme = "https";
}

This is not a replacement for forwarded headers.

It is a Cloudflare-specific fallback for deployments where X-Forwarded-Proto is missing, not processed as expected, or there is another proxy between Cloudflare and the app.

If your infrastructure reliably forwards and processes X-Forwarded-Proto, you may not need this fallback.

Why this matters for Auth0 and OpenID Connect

Authentication is usually where reverse proxy mistakes become obvious.

In this project the Umbraco backoffice uses Auth0 through OpenID Connect. The app configures a callback path such as:

{
  "Auth0": {
    "Authority": "https://YOUR_AUTH0_DOMAIN",
    "ClientId": "YOUR_CLIENT_ID",
    "ClientSecret": "YOUR_CLIENT_SECRET",
    "CallbackPath": "/umbraco-auth0-signin"
  }
}

The identity provider expects the redirect URI to match the value registered in its application settings:

https://www.example.com/umbraco-auth0-signin

If ASP.NET Core thinks the current request is http://internal-container:8080, the authentication middleware can generate the wrong external URL.

The result is usually a redirect URI mismatch or a login loop.

When troubleshooting OpenID Connect behind a proxy, I check these first:

  • does the request reaching ASP.NET Core include X-Forwarded-Proto?
  • does it include X-Forwarded-Host?
  • is UseForwardedHeaders() running before authentication and Umbraco middleware?
  • is the public callback URL registered in Auth0?
  • is the origin protected so random clients cannot spoof forwarded headers?

Why this matters for SEO URLs

The same issue affects SEO infrastructure.

In the sitemap article I used code like this:

var host = Request.Host.Value;
var hostWithScheme = $"https://{host}";

And the Razor templates use values such as:

Context.Request.GetDisplayUrl().Split('?').First()

or:

$"https://{Context.Request.Host}/"

If the app sees the wrong host, generated SEO URLs can be wrong:

http://internal-container:8080/my-page

instead of:

https://www.example.com/my-page

That can leak into:

  • canonical links;
  • Open Graph tags;
  • sitemap entries;
  • JSON-LD url and @id values;
  • redirect targets;
  • generated backoffice links.

This is why I prefer solving proxy forwarding centrally instead of patching every SEO helper with special cases.

UmbracoApplicationUrl

Umbraco also supports setting the public application URL in configuration.

For example:

{
  "Umbraco": {
    "CMS": {
      "WebRouting": {
        "UmbracoApplicationUrl": "https://www.example.com"
      }
    }
  }
}

That setting is useful, but I do not treat it as a substitute for forwarded headers.

Forwarded headers fix the current request. UmbracoApplicationUrl gives Umbraco a configured public URL for scenarios that need it.

In practice, I want both to agree.

Safer production configuration

The convenient configuration is:

{
  "ProxyForwarding": {
    "TrustAllForwardedHeaders": true,
    "ForwardLimit": null,
    "ApplyCloudflareVisitorScheme": true
  }
}

But the safer production question is:

Who is allowed to send these headers?

If your origin is reachable only from Cloudflare or from your private reverse proxy, trusting forwarded headers can be acceptable as part of that boundary.

If your origin is reachable from the public internet, it is not acceptable.

In stricter deployments, configure known proxies or known networks:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor |
        ForwardedHeaders.XForwardedProto |
        ForwardedHeaders.XForwardedHost;

    options.KnownProxies.Add(IPAddress.Parse("10.0.0.10"));
    options.ForwardLimit = 1;
});

Or use KnownIPNetworks if the proxy sits in a known subnet.

The exact values depend on your hosting topology. The important part is to make the trust boundary explicit.

A practical debugging endpoint

When debugging proxy issues, I like adding a temporary endpoint or log scope that shows what ASP.NET Core sees:

app.MapGet("/debug/request", (HttpContext context) => new
{
    scheme = context.Request.Scheme,
    isHttps = context.Request.IsHttps,
    host = context.Request.Host.ToString(),
    pathBase = context.Request.PathBase.ToString(),
    remoteIp = context.Connection.RemoteIpAddress?.ToString(),
    xForwardedFor = context.Request.Headers["X-Forwarded-For"].ToString(),
    xForwardedProto = context.Request.Headers["X-Forwarded-Proto"].ToString(),
    xForwardedHost = context.Request.Headers["X-Forwarded-Host"].ToString(),
    cfVisitor = context.Request.Headers["CF-Visitor"].ToString()
});

Do not leave this public in production.

But during setup, it answers the important question immediately:

What does the application think the request is?

What I would test

The project has a small test for the default proxy options:

[Fact]
public void Defaults_PreserveCurrentBootstrapBehavior()
{
    var options = new ProxyForwardingOptions();

    options.TrustAllForwardedHeaders.Should().BeTrue();
    options.ForwardLimit.Should().BeNull();
    options.ApplyCloudflareVisitorScheme.Should().BeTrue();
}

That protects the current bootstrap behavior.

For a larger deployment, I would add integration tests around request behavior:

  • with X-Forwarded-Proto: https, generated canonical URLs use https;
  • with X-Forwarded-Host: www.example.com, generated absolute URLs use the public host;
  • Auth0 callback URLs match the public URL;
  • sitemap entries use the public host;
  • CF-Visitor: {"scheme":"https"} fixes the scheme only when the Cloudflare fallback is enabled;
  • spoofed forwarded headers are ignored when the request does not come from a known proxy.

That last one is the security test.

Common failure modes

The failures usually look like this:

  1. Login works locally but fails behind Cloudflare.
  2. Auth0 says the redirect URI does not match.
  3. The app redirects to http:// even though the browser used https://.
  4. Canonical URLs point to an internal host.
  5. Sitemap entries use the container port.
  6. Request.IsHttps is false in production.
  7. Disabling HTTPS redirects appears to “fix” a loop, but only hides the proxy misconfiguration.

When I see any of these, I check forwarded headers before touching Umbraco.

Final thought

Reverse proxy support is not an Umbraco feature you add at the end.

It is part of the foundation of the application.

If Request.Scheme and Request.Host are wrong, everything built on top of them becomes unreliable: authentication, redirects, canonical URLs, sitemap entries, Open Graph tags and structured data.

The durable setup is:

  • configure forwarded headers explicitly;
  • run UseForwardedHeaders() early;
  • handle Cloudflare-specific behavior only where needed;
  • do not trust forwarded headers from untrusted clients;
  • verify what ASP.NET Core sees before debugging Umbraco.

Once that foundation is correct, the rest of the site can generate public URLs without knowing or caring how many proxies sit in front of it.

References