Mert Özen Further With Every Line
DevOps SSL/TLS Certificates and HTTPS — #06

Enforcing HTTPS: HTTP→HTTPS Redirects, Rolling Out HSTS Gradually, Preload and Mixed Content

Mert Özen Nov 14, 2026 16 min 4 views
Enforcing HTTPS: HTTP→HTTPS Redirects, Rolling Out HSTS Gradually, Preload and Mixed Content

To enforce HTTPS you redirect at the layer where TLS terminates, reject HTTP with 400 instead of redirecting on APIs, and roll HSTS out gradually from 5 minutes to a year; a subdomain inventory is a must before includeSubDomains. With real runs we see the UseHttpsRedirection loop and the non-secure cookie behind a proxy, 301 turning a POST into a GET, HSTS locking an HTTP-only subdomain, and an absolute http:// URL in an Angular build being blocked as mixed content.

Version note: The examples in this post were run with Nginx 1.30.6, .NET 10 (SDK 10.0.402, ASP.NET Core runtime 10.0.15), Angular 22.2, curl 8.14 and Playwright 1.65 (Chromium) on Docker 29; the certificates come from a test CA created for the lab.

Guess First

All of Bookstore's domains now run with valid certificates, and the team takes two steps to enforce HTTPS (HTTP Secure). First: return 301 https://$host$request_uri; for every request arriving on port 80 in Nginx. Second: a Strict-Transport-Security: max-age=31536000; includeSubDomains header on the HTTPS responses of bookstore.example.

Three questions. An old mobile version still sends orders to POST http://www.bookstore.example/api/orders; does the order reach the API? What happens the next day to reports.bookstore.example, the internal report site accounting has opened over HTTP for years? If the mistake is noticed and the header is removed, when does the report site recover? We'll see all of it with real runs.

Where Should the Redirect Happen?

There are three layers where you can redirect from HTTP to HTTPS: the CDN, the reverse proxy and the application itself. The rule is simple: redirect at the layer where TLS terminates. That layer knows best whether the request arrived encrypted. At Bookstore TLS ends at Nginx, so the redirect lives there too:

server {
    listen 80;
    server_name www.bookstore.example bookstore.example;

    location /api/ {
        return 308 https://$host$request_uri;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 80;
    server_name api.bookstore.example;
    return 400 "HTTPS required\n";
}
$ curl -sI "http://www.bookstore.example/books?page=2" | grep -E "HTTP|Location"
HTTP/1.1 301 Moved Permanently
Location: https://www.bookstore.example/books?page=2

$host and $request_uri keep the path and the query string; the user lands on the same page as the bookmarked address. I'll explain the 308 for /api/ and the 400 instead of a redirect for the API host in the next section. If TLS terminates at a CDN, do the redirect with the CDN's own setting too; if the connection between the CDN and the origin is HTTP and the origin also redirects, you get a loop that never ends. The CDN side, together with DNS records, is a topic for the Domain, DNS and CDN series. The basics of the HTTPS server block in Nginx are in the Web Servers and Hosting series post "HTTPS, Security Headers, Rate Limiting and Load Balancing in Nginx".

ASP.NET Core templates come with app.UseHttpsRedirection(), and that line stays behind a proxy too. The problem: Nginx terminates TLS and forwards the request to Kestrel over plain HTTP. In Kestrel's eyes every request is HTTP. I set up Bookstore.Api for this post like this:

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
builder.Services.AddHttpsRedirection(options => options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    // only the Nginx container's network is trusted
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.30.0.0/16"));
});

WebApplication app = builder.Build();

if (app.Configuration.GetValue<bool>("Proxy:UseForwardedHeaders"))
{
    app.UseForwardedHeaders();
}

// API: never redirect, reject plain HTTP
app.Use(async (context, next) =>
{
    if (!context.Request.IsHttps && context.Request.Path.StartsWithSegments("/api"))
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        await context.Response.WriteAsync("HTTPS required\n");
        return;
    }

    await next(context);
});

app.UseHttpsRedirection();

app.MapGet("/api/health", (HttpContext context) => $"ok scheme={context.Request.Scheme}\n");
app.MapPost("/api/orders", (HttpContext context) => $"{context.Request.Method} /api/orders accepted\n");
app.MapGet("/account/login", async (HttpContext context) =>
{
    ClaimsPrincipal user = new(new ClaimsIdentity([new Claim(ClaimTypes.Name, "ada")], CookieAuthenticationDefaults.AuthenticationScheme));
    await context.SignInAsync(user);
    return $"signed in, scheme={context.Request.Scheme}\n";
});

app.Run();

The Proxy:UseForwardedHeaders switch exists only to show the difference; in a real app UseForwardedHeaders is unconditional and sits at the very start of the pipeline. I did three runs. In the first, forwarded headers are off and the HTTPS port is undefined:

$ curl -sS -D - https://www.bookstore.example/account/login | grep -iE "^HTTP|set-cookie|signed"
HTTP/2 200
set-cookie: .AspNetCore.Cookies=CfDJ8...; path=/; samesite=lax; httponly
signed in, scheme=http
$ curl -sS https://www.bookstore.example/api/health
HTTPS required
$ docker logs tlsseries-api 2>&1 | grep -A1 HttpsRedirectionMiddleware
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
      Failed to determine the https port for redirect.

The browser came over HTTPS, but the app sees scheme=http. Since the middleware doesn't know which port to redirect to, it logs a warning and lets the request through. The real loss is the cookie: the authentication cookie goes out without the secure flag, because cookie authentication's default SecurePolicy is SameAsRequest, and in the app's eyes the request is HTTP. That cookie will be sent in clear text the day the user goes to any http:// address. Our HTTP-rejecting middleware also rejects a legitimate API request that came over HTTPS.

In the second run someone says "the redirect doesn't work" and adds ASPNETCORE_HTTPS_PORT=443:

$ curl -sS -L -o /dev/null -w "%{num_redirects} redirects, last %{http_code} %{url_effective}\n" \
    https://www.bookstore.example/account/login
curl: (47) Maximum (50) redirects followed
50 redirects, last 308 https://www.bookstore.example/account/login

The classic loop: the app takes the HTTPS request for HTTP and redirects it to HTTPS, the browser comes back to the same HTTPS address, and Nginx forwards it over HTTP again. In a browser this shows up as a "too many redirects" error. In the third run I turned forwarded headers on:

$ curl -sS -D - -o /dev/null https://www.bookstore.example/account/login | grep -i set-cookie
set-cookie: .AspNetCore.Cookies=CfDJ8...; path=/; secure; samesite=lax; httponly
$ curl -sS https://www.bookstore.example/api/health
ok scheme=https
$ curl -sS http://api:8080/api/health
HTTPS required
$ curl -sSI http://api:8080/account/login | grep -iE "^HTTP|location"
HTTP/1.1 308 Permanent Redirect
Location: https://api/account/login

The app now reads the scheme from X-Forwarded-Proto: the cookie is secure, the API works, and a plain HTTP request that bypasses the proxy and hits Kestrel directly is rejected or redirected. KnownIPNetworks is critical here. This header must be trusted only when it comes from the proxy's IP address; otherwise anyone can write X-Forwarded-Proto: https and fool the app. The mechanism itself is covered in the Web Servers and Hosting series post "ASP.NET Core Behind a Reverse Proxy: Forwarded Headers, KnownIPNetworks and the Real Client IP". If Nginx does the redirect, UseHttpsRedirection in the app is unnecessary; if you keep it, give the HTTPS port explicitly and run forwarded headers before it. Don't leave cookies to chance either: options.Cookie.SecurePolicy = CookieSecurePolicy.Always guarantees the secure flag even if the proxy setup breaks.

For APIs, Reject Instead of Redirect

Back to the first question from Guess First. The Nginx config has 301 for pages and 308 for /api/. I sent the same POST to both; -L makes curl follow the redirect:

$ curl -sL --cacert root.crt -d '{"bookId":42}' -H "Content-Type: application/json" -o /dev/null \
    -w "%{num_redirects} redirect -> %{method} %{url_effective} %{http_code}\n" http://www.bookstore.example/orders
1 redirect -> GET https://www.bookstore.example/orders 200
$ curl -sL --cacert root.crt -d '{"bookId":42}' -H "Content-Type: application/json" \
    http://www.bookstore.example/api/orders
POST /api/orders accepted

Following the 301, curl turned the POST into a GET and dropped the body; browsers historically do the same. The result is 200, but there's no order: the client got a successful response while the server just returned the home page. 308 keeps the method and the body, and the order arrived. So the answer to the first question depends on the redirect code and how the client follows it. With 301 the order silently disappears.

308 isn't enough for an API either. Look at what happens when there's a token in the request:

$ curl -sv -L --cacert root.crt -H "Authorization: Bearer eyJhbGciOi.demo" -d '{"bookId":42}' \
    http://www.bookstore.example/api/orders 2>&1 | grep -E "^(> (POST|Host|Authorization)|< HTTP|\* (Connected|Issue|SSL connection))"
* Connected to www.bookstore.example (127.0.0.1) port 80
> POST /api/orders HTTP/1.1
> Host: www.bookstore.example
> Authorization: Bearer eyJhbGciOi.demo
< HTTP/1.1 308 Permanent Redirect
* Issue another request to this URL: 'https://www.bookstore.example/api/orders'
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / RSASSA-PSS
* Connected to www.bookstore.example (127.0.0.1) port 443
> POST /api/orders HTTP/2
> Host: www.bookstore.example
< HTTP/2 200

The token went out in clear text over port 80 before the redirect came back. Anyone on the network could have read it, and the redirect can't do anything about that, because the damage is done. One more detail: on a redirect that changes scheme and port, curl deliberately did not resend the Authorization header. In a real API the second request would get a 401, and a developer would lose hours on "the token doesn't work". Microsoft's guidance points the same way: APIs should either not listen on HTTP or reject HTTP requests with 400; a redirect is something API clients may not understand or obey. At Bookstore api.bookstore.example doesn't redirect on port 80; Nginx returns 400. If Kestrel faces the internet directly, the middleware above does the same job; we just saw the 400 it returned to an HTTP request sent straight to Kestrel:

$ curl -sv -H "Authorization: Bearer eyJhbGciOi.demo" -d '{"bookId":42}' http://api.bookstore.example/api/orders 2>&1 \
    | grep -E "^(> (POST|Authorization)|< HTTP)|HTTPS required"
> POST /api/orders HTTP/1.1
> Authorization: Bearer eyJhbGciOi.demo
< HTTP/1.1 400 Bad Request
HTTPS required

The token still went out in clear text. Rejecting doesn't take it back, but it makes the mistake visible: a client configured with the wrong URL breaks on its first test and gets fixed. A redirect makes the same mistake look like it works for months. Rotate the leaked token as well. On the browser side there's another reason: a CORS preflight request doesn't follow redirects and fails.

HSTS: Telling the Browser "Never Try HTTP Again"

A redirect can't stop the first request from going over HTTP. The user types bookstore.example into the address bar, the browser tries HTTP, and an attacker can tamper with that first response so the user never moves to HTTPS (SSL stripping). HSTS closes that gap; in this mechanism, defined in RFC 6797, the header in an HTTPS response tells the browser "come to this host only over HTTPS for max-age seconds". curl's HSTS cache is a good tool for showing the browser's behavior in a console:

$ curl -sS -o /dev/null -D - --cacert root.crt --hsts hsts.txt https://www.bookstore.example/ | grep -i strict
strict-transport-security: max-age=300
$ grep -v "^#" hsts.txt
www.bookstore.example "20261113 22:44:49"
$ curl -sv -o /dev/null --cacert root.crt --hsts hsts.txt http://www.bookstore.example/books 2>&1 | grep -E "HSTS|Connected|^< HTTP"
* Switched from HTTP to HTTPS due to HSTS => https://www.bookstore.example/books
* Connected to www.bookstore.example (127.0.0.1) port 443
< HTTP/2 200

On the second request port 80 wasn't contacted at all. The client turned http:// into https:// internally; browsers do the same. HSTS has three rules. The header only counts in an HTTPS response; a header you put in an HTTP response is ignored, because someone in the middle could add or strip it. If there's a certificate error on an HSTS host, the browser doesn't offer "proceed anyway". The third, and the most often forgotten: the entry is kept per host, not per port. We'll see that in a moment.

Rolling Out HSTS Gradually

The risk of HSTS is that it's hard to undo. The moment you send max-age=31536000, every browser that receives that response refuses to use HTTP for that host for a year. Even if you notice a mistake and remove the header, the effect doesn't go away. That's why the rollout must be gradual.

Stages

For a first HTTPS rollout Microsoft recommends starting max-age in hours, at most one day. The steps hstspreload.org recommends are 5 minutes (300), 1 week (604800), 1 month (2592000) and finally 1 year. At each step, wait for that step's max-age before moving to the next, and look for broken pages, error rates and support tickets. Rolling back a step leaves an effect for at most that step's duration. At Bookstore the plan was: 300 seconds on www, then a week on the apex, includeSubDomains once the subdomain inventory was complete, and a year at the end. The ASP.NET Core side of producing the header (AddHsts, MaxAge, ExcludedHosts) is in the fourth post of the ASP.NET Core Security series, the one that emits security headers; here our topic is only the rollout plan.

Inventory Before includeSubDomains

The second question from Guess First. I added includeSubDomains on the apex, visited the apex first and then went to the internal report site:

$ curl -sS -o /dev/null -D - --cacert root.crt --hsts hsts.txt https://bookstore.example/ | grep -i strict
strict-transport-security: max-age=604800; includeSubDomains
$ grep -v "^#" hsts.txt
www.bookstore.example "20261113 22:44:49"
.bookstore.example "20261120 22:39:56"
$ curl -sS -v -o /dev/null --cacert root.crt --hsts hsts.txt http://reports.bookstore.example/ 2>&1 | grep -E "HSTS|subject|curl:"
* Switched from HTTP to HTTPS due to HSTS => https://reports.bookstore.example/
*  subject: CN=www.bookstore.example
*  subjectAltName does not match hostname reports.bookstore.example
* SSL: no alternative certificate subject name matches target hostname 'reports.bookstore.example'
curl: (60) SSL: no alternative certificate subject name matches target hostname 'reports.bookstore.example'

The leading dot in the entry (.bookstore.example) covers all subdomains. The report site can no longer be reached over HTTP; over HTTPS there's either no certificate or one with the wrong name. Since a certificate error can't be bypassed on an HSTS host, there's nothing the user can do. The answer to Guess First: from the moment accounting visits the apex, they can't open the report site for a week. To remove it, I sent max-age=0:

$ curl -sS -o /dev/null --cacert root.crt --hsts hsts.txt https://bookstore.example/
$ grep -v "^#" hsts.txt
www.bookstore.example "20261113 22:44:49"
$ curl -sS --hsts hsts.txt http://reports.bookstore.example/
reports (internal, HTTP only)

The entry was deleted, but only for a client that comes back to the apex over HTTPS. Every user whose browser has the entry needs to visit the apex once more; for those who don't, the entry stays until max-age runs out. So before includeSubDomains, list every name in the DNS zone: internal tools, old campaign sites, panels opened over a VPN, tracking subdomains of email services. Each one needs HTTPS or must move to a different domain. Certificate Transparency logs are a good starting point for this inventory: as we saw with crt.sh in the second post, every subdomain that ever got a certificate shows up there. An HTTP-only subdomain never shows up there at all, so you have to find it in the DNS zone.

Preload: A Decision That's Hard to Reverse

HSTS doesn't protect the first visit; the browser needs at least one HTTPS response to see the header. The preload list closes that gap: a list managed by Chromium and built into browsers. Even the first request to a domain on this list goes over HTTPS. hstspreload.org's acceptance requirements are: a valid certificate, a redirect from HTTP to HTTPS on the same host, HTTPS on every subdomain including internal ones, and an HSTS header on the apex with at least max-age=31536000, includeSubDomains and preload. Going back is hard: removal from the list reaches users through a Chrome update, which takes months, and there's no guarantee for other browsers. Consider preload only after the last step of the gradual rollout has run a month without trouble and you're sure every subdomain is on HTTPS. It makes sense for a bank or an admin panel. In an organization where different teams run subdomains and you don't know who opened what, it's risky.

Mixed Content and Absolute URLs in Angular

When a page comes over HTTPS and requests one of its resources over HTTP, that's called mixed content. In Bookstore's Angular app the API address is written in the environment file as an absolute URL, and the cover image is hardcoded in the template:

export const environment = {
  apiUrl: 'http://www.bookstore.example',
};
@Component({
  selector: 'app-root',
  template: `
    <h1>Bookstore</h1>
    <img src="http://www.bookstore.example/cover.png" width="40" height="40" alt="cover">
    <p id="status">{{ status() }}</p>
  `,
})
export class App {
  protected readonly status = signal('loading');

  constructor() {
    inject(HttpClient)
      .get(`${environment.apiUrl}/api/health`, { responseType: 'text' })
      .subscribe({
        next: body => this.status.set(body.trim()),
        error: () => this.status.set('api call failed'),
      });
  }
}

At build time the environment value is baked into the bundle; there's no setting left that you can change at runtime:

$ npx ng build
$ grep -o 'http://www.bookstore.example[^"`]*' dist/bookstore-web/browser/main-*.js | sort | uniq -c
   1 http://www.bookstore.example
   1 http://www.bookstore.example/cover.png

I opened the page in Chromium with Playwright and collected the console, failed requests and responses:

import { chromium } from 'playwright';

const browser = await chromium.launch({
  args: ['--host-resolver-rules=MAP *.bookstore.example 127.0.0.1'],
});
// lab only: the test root is not in the browser's trust store
const page = await browser.newPage({ ignoreHTTPSErrors: true });

page.on('console', m => console.log(`[console.${m.type()}] ${m.text()}`));
page.on('requestfailed', r => console.log(`[failed] ${r.url()} ${r.failure()?.errorText}`));
page.on('response', r => {
  if (r.url().includes('/api/') || r.url().endsWith('.png')) {
    console.log(`[response] ${r.status()} ${r.url()}`);
  }
});

await page.goto('https://www.bookstore.example/');
await page.waitForFunction(() => document.querySelector('#status')?.textContent !== 'loading');
console.log(`[status] ${await page.textContent('#status')}`);
await browser.close();
$ node mixed.mjs
[console.error] Mixed Content: The page at 'https://www.bookstore.example/' was loaded over HTTPS, but requested an insecure resource 'http://www.bookstore.example/api/health'. This request has been blocked; the content must be served over HTTPS.
[failed] http://www.bookstore.example/api/health mixed-content
[console.warning] Mixed Content: The page at 'https://www.bookstore.example/' was loaded over HTTPS, but requested an insecure element 'http://www.bookstore.example/cover.png'. This request was automatically upgraded to HTTPS, For more information see https://blog.chromium.org/2019/10/no-more-mixed-messages-about-https.html
[response] 200 https://www.bookstore.example/cover.png
[status] api call failed

There are two different treatments. The API call was blocked: resources that can change the page's behavior, such as scripts, fetch and XHR (XMLHttpRequest), are blocked as mixed content. The image was upgraded to HTTPS by Chromium on its own and loaded. The result: the page opens, the cover shows, but no data arrives. Because everything runs over http://localhost in development, this bug only shows up in production.

As a temporary patch I added add_header Content-Security-Policy "upgrade-insecure-requests" always; to the HTTPS server block in Nginx. This CSP directive tells the browser to turn every http:// request on the page into https:// before sending it:

$ node mixed.mjs
[response] 200 https://www.bookstore.example/cover.png
[response] 200 https://www.bookstore.example/api/health
[status] ok scheme=https

The real fix is to make the URL relative. The API is on the same origin and Nginx forwards /api/ to Kestrel; there's no need for an absolute URL. I rebuilt with apiUrl: '' and src="/cover.png", and removed the CSP header:

$ grep -c 'http://www.bookstore' dist/bookstore-web/browser/main-*.js
0
$ node mixed.mjs
[response] 200 https://www.bookstore.example/cover.png
[response] 200 https://www.bookstore.example/api/health
[status] ok scheme=https

If the API really is on a different domain, write it with https:// in the environment and run a check in CI that looks for http:// in the build output. You can keep upgrade-insecure-requests as a permanent safety belt; but if the upgraded address doesn't serve HTTPS, the request still fails, so it doesn't replace a real fix.

In Production

Manage HSTS as a calendar, not a deploy. Write down each step's date, its max-age and the criteria for moving to the next step (error rate, broken page reports). If a step goes wrong, know that the time to roll back is that step's max-age.

Test redirects and HSTS from the outside. A smoke test after every deploy that measures the redirect code and Location with curl -sI http://... and Strict-Transport-Security with curl -sI https://... catches config changes that silently drop the header.

Ask for Secure on cookies explicitly. Set Cookie.SecurePolicy = CookieSecurePolicy.Always separately on the authentication cookie and on the antiforgery cookie; even if the proxy setup breaks, the cookie won't leak over HTTP. The details of antiforgery and cookie flags are in the Web Security series, in the second post covering OWASP A07.

For APIs, closing the HTTP port is the cleanest solution. If the load balancer or Gateway listens only on 443, the chance of a token going over HTTP disappears. If port 80 is shared (a website on the same IP address), return 400 for the API host and watch the logs for clients still trying HTTP.

Common Pitfalls

Turning on HSTS in development and locking up localhost: HSTS works per host, not per port. On the same machine, an app running HTTPS on 5001 sent HSTS for localhost, and then I went to a plain HTTP app on 5000:

$ curl -sS --cacert lh.crt --hsts lh.txt https://localhost:5001/
app A on 5001
$ curl -sv --hsts lh.txt http://localhost:5000/ 2>&1 | grep HSTS
* Switched from HTTP to HTTPS due to HSTS => https://localhost:5000/
$ curl -sS --hsts lh.txt http://localhost:5000/
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number

The same happens in a browser: because of one app's header, every localhost app on the machine is forced to HTTPS. That's why ASP.NET Core's UseHsts excludes localhost, 127.0.0.1 and [::1] by default and runs only outside development in the template. If you write the HSTS header in your own proxy, you have to build that protection yourself.

Breaking an HTTP-only internal subdomain with includeSubDomains: we saw it above. includeSubDomains added without an inventory shuts those subdomains for max-age for everyone who visits the apex.

The header disappearing on some paths in Nginx: in Nginx, add_header is inherited from a higher level only if there's no add_header at all at the current level. I tried a server block with HSTS at the server level and a /assets/ location that only adds Cache-Control, with two paths:

== /
Strict-Transport-Security: max-age=31536000; includeSubDomains
== /assets/main.js
Cache-Control: public, max-age=31536000, immutable

Responses under /assets/ have no HSTS. Since the browser learns HSTS from the first HTTPS response, this alone isn't a disaster; but the same rule drops CSP and the other security headers too. Collect the headers in an include file and add that file to every location that uses add_header.

Redirecting with 301 and forgetting the API: 301 and 302 can turn a POST into a GET. 301 is fine for pages, but if form posts or API calls sit behind the same rule, either use 307/308 or keep the API host separate and reject.

A Small Experiment

★☆☆ Run curl -sI http://yourdomain/ and curl -sI https://yourdomain/ against your own site. What's the redirect code, does Location keep the path and the query string, is there an HSTS header and with what max-age?

★★☆ In this post's ASP.NET Core app, add options.Cookie.SecurePolicy = CookieSecurePolicy.Always to AddCookie and call /account/login again with forwarded headers off. How did Set-Cookie change? Why does the cookie still work even though the app sees HTTP?

★★★ Write an HSTS rollout plan for the Bookstore domain: list every subdomain from the DNS zone and crt.sh, mark the ones without HTTPS, and set the date and the exit criteria for each step. Then apply the preload requirements to that list: if you applied today, which subdomain would break?

We've enforced HTTPS; now we're sure of the server's identity. In the next post we turn the question around: how do we become sure of the client's identity? mTLS (mutual TLS), client certificate authentication in ASP.NET Core and running your own internal CA.