/ โ† Security Audit Fixes / Mixed Content Behind a Proxy
In this guide
  1. What the mixed content error means behind a proxy
  2. Common causes
  3. How to find where the https is lost
  4. Setting X-Forwarded-Proto at the proxy
  5. The application changes: Laravel and Keycloak
  6. Platform notes
  7. The fix, step by step
  8. Edge cases worth knowing
  9. Keeping it fixed
  10. Questions and answers

What the mixed content error means behind a proxy

A mixed content error means a page delivered over https asked the browser to load something over plain http. Browsers block active content such as scripts, stylesheets, iframes and form targets outright, because an attacker on the network could change them. Images, audio and video are treated as passive content, and current Chrome and Firefox try to upgrade those to https automatically before giving up.

In Chrome the console message looks like this:

Mixed Content: The page at 'https://app.example.com/login' was loaded over HTTPS,
but requested an insecure stylesheet 'http://app.example.com/css/app.css'.
This request has been blocked; the content must be served over HTTPS.

On a normal single server the application terminates TLS itself and knows the request was secure. Behind a proxy the picture changes:

Browser --https--> Load balancer / nginx / Cloudflare --http--> Laravel or Keycloak

The application receives an ordinary http request on port 80 or 8080. Unless something tells it otherwise, it believes the visitor is on http, so helpers such as asset(), url(), route() and redirect builders all produce http:// addresses. Keycloak does the same with its login form action, its resource paths and the redirect it sends back to your application.

The standard way to pass the original scheme along is the X-Forwarded-Proto header, set by the proxy to https. The newer standard Forwarded header carries the same information as proto=https. The application must also be told which proxies it may believe, otherwise it ignores those headers, which is the correct and safe default.

Common causes

Almost every case comes down to one of these. Work out which applies before changing code, because forcing https in the application can hide a broken proxy that will bite you elsewhere.

CauseWhat you seeWhere to fix it
Proxy does not send X-Forwarded-ProtoAll generated links are http on every pagenginx, Apache, HAProxy or Traefik configuration
Application does not trust the proxyHeader arrives but links are still httpLaravel TrustProxies, Keycloak proxy-headers
Two proxies in a rowThe inner proxy overwrites https with httpInner proxy must pass the outer value through
Cloudflare Flexible SSLOrigin always sees http, often with a redirect loopSet SSL mode to Full (strict) and install a certificate at the origin
Wrong APP_URL or KC_HOSTNAMEEmails, queued jobs or tokens use httpEnvironment variables
Hard-coded http in templates or the databaseOnly some pages or images affectedContent, not the proxy

If only one or two images on a page are http and everything else is fine, you probably have the last cause, which the WordPress mixed content guide covers in detail. If every generated address is http, the proxy chain is the problem.

How to find where the https is lost

Start in the browser. Open DevTools, go to the Console and reload. Note the exact insecure addresses, then open the Network panel and look at the response headers of the page itself, especially any Location header on a redirect after login.

Next, see what the server sends without the browser getting involved:

curl -s https://app.example.com/login | grep -o 'http://[^"]*' | sort -u
curl -sI https://app.example.com/dashboard | grep -i '^location'

If the first command prints addresses on your own domain, the application is generating them. Now check what actually reaches the application. Temporarily add a route in Laravel and visit it through the proxy:

// routes/web.php (remove after testing)
Route::get('/proxy-test', fn (\Illuminate\Http\Request $r) => [
    'scheme'     => $r->getScheme(),
    'secure'     => $r->isSecure(),
    'xfp_header' => $r->header('X-Forwarded-Proto'),
    'client_ip'  => $r->ip(),
    'remote'     => $r->server('REMOTE_ADDR'),
]);

Read the result like this:

For Keycloak, request the OpenID discovery document and check every endpoint starts with https:

curl -s https://auth.example.com/realms/myrealm/.well-known/openid-configuration | grep -o '"[a-z_]*endpoint": *"[^"]*"'
๐Ÿ’ก The discovery document is the quickest Keycloak test. If the issuer or any endpoint begins with http, the login page, its form and its redirects will too.

Setting X-Forwarded-Proto at the proxy

The proxy that terminates TLS must state the original scheme. In nginx, inside the https server block:

location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Port  $server_port;
}

In Apache with mod_proxy and mod_headers, inside the port 443 virtual host:

ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
ProxyPass        / http://127.0.0.1:8000/
ProxyPassReverse / http://127.0.0.1:8000/

AWS Application Load Balancers, Traefik and most Kubernetes ingress controllers add X-Forwarded-Proto by default, so with those the fault is usually trust in the application, or an extra nginx layer that replaces the header.

Two proxies in a row

If a load balancer or Cloudflare sits in front of your own nginx, $scheme in nginx is the scheme of the hop from the load balancer, which may be http. Pass the outer value through instead:

map $http_x_forwarded_proto $fwd_proto {
    default $http_x_forwarded_proto;
    ''      $scheme;
}
# then in the location block
proxy_set_header X-Forwarded-Proto $fwd_proto;
โš ๏ธ Only pass an incoming X-Forwarded-Proto through when nginx can only be reached from the outer proxy. If the server is open to the internet, anyone can send that header.

The application changes: Laravel and Keycloak

Laravel 11 and later

Trust the proxy in bootstrap/app.php. Use the proxy's address or private range where you know it:

use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
        headers: Request::HEADER_X_FORWARDED_FOR |
                 Request::HEADER_X_FORWARDED_HOST |
                 Request::HEADER_X_FORWARDED_PORT |
                 Request::HEADER_X_FORWARDED_PROTO
    );
})

Laravel 10 and earlier

Edit app/Http/Middleware/TrustProxies.php and set the $proxies property to the proxy addresses, or to '*' if the application is only reachable through the proxy. The default $headers value already includes the forwarded proto header.

forceScheme as a backstop

If the proxy cannot be changed, for example on some managed platforms, force https for generated addresses in AppServiceProvider:

public function boot(): void
{
    if ($this->app->environment('production')) {
        \Illuminate\Support\Facades\URL::forceScheme('https');
    }
}

This fixes links and assets but does not make $request->isSecure() true, so secure cookies and anything that checks the request itself still need trusted proxies. Also set APP_URL=https://app.example.com, which is what Artisan commands, queued mail and scheduled jobs use, since they have no incoming request.

Keycloak 25 and later

Current Keycloak replaces the old --proxy edge option with --proxy-headers. With TLS ending at the proxy:

bin/kc.sh start \
  --hostname https://auth.example.com \
  --proxy-headers xforwarded \
  --http-enabled true

Or as environment variables in Docker: KC_HOSTNAME=https://auth.example.com, KC_PROXY_HEADERS=xforwarded and KC_HTTP_ENABLED=true. Use forwarded instead of xforwarded if your proxy sends the standard Forwarded header. Setting the full https hostname also fixes the issuer in tokens, which otherwise breaks sign-in for your client applications.

Platform notes

Once the scheme is right, add Strict-Transport-Security so browsers never try http first. The HSTS guide shows how, and security headers in nginx covers the rest of the header set.

The fix, step by step

Step 1

Record the exact insecure addresses

Open the affected page with DevTools open, reload, and copy every mixed content message from the Console. Note whether the blocked items are stylesheets, scripts, form targets or redirects, and whether they point at your own domain. Then run curl -s on the page and grep for http:// to confirm the server, not a browser extension or cache, is producing them. Keep this list, because you will test against it at the end.

Step 2

Check what the application receives

Add a temporary route that returns the request scheme, isSecure(), the X-Forwarded-Proto header and REMOTE_ADDR, and open it through the proxy. If the header is missing, the proxy is at fault. If it says https but the request is not secure, the application does not trust the proxy. If it says http, an inner layer or Cloudflare Flexible mode is overwriting it. Remove the route once you know.

Step 3

Send the forwarded headers from the proxy

In the nginx or Apache configuration that terminates TLS, set X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port and X-Forwarded-For. With two proxies, make the inner one pass the outer value through instead of using its own scheme. Test the configuration with nginx -t or apachectl configtest, reload the service, and revisit the test route to confirm the header now arrives with the value https. Keep the old configuration file so you can roll back.

Step 4

Trust the proxy in Laravel

In Laravel 11 or later call trustProxies in bootstrap/app.php with your proxy addresses and the forwarded header flags. In older versions set the $proxies property in the TrustProxies middleware. Clear the configuration cache with php artisan config:clear, then reload the test route. It should now report the scheme as https and the client address as the visitor rather than the proxy. Remove any old setting that trusted no proxies at all.

Step 5

Set the https base addresses

Set APP_URL to the full https address so queued jobs, scheduled tasks and emails build correct links. If you cannot control the proxy headers, add URL::forceScheme('https') in the service provider boot method for production. Run php artisan config:cache again on deployment. Confirm by sending yourself a password reset email and checking that its link starts with https. Check a queued notification as well.

Step 6

Configure Keycloak for the proxy

Start Keycloak with --hostname set to the full https public address, --proxy-headers xforwarded or forwarded to match your proxy, and --http-enabled true when TLS ends at the proxy. Remove any old --proxy edge option from scripts and containers. Restart the service and fetch the realm discovery document with curl; every endpoint and the issuer must begin with https. Then sign in to a client application to confirm tokens are accepted.

Step 7

Rebuild assets and purge caches

Rebuild front-end assets so compiled manifests carry the right addresses, then purge any full-page cache, compiled view cache and CDN cache. In Laravel run php artisan view:clear and php artisan cache:clear, and restart PHP-FPM if you use OPcache with file timestamps off. Without this, visitors can keep receiving old pages with http links for hours even though the server is now generating correct ones.

Step 8

Retest every affected page

Repeat the curl grep on each page from your original list, sign in and out through Keycloak, and watch the Console and Network panels for any remaining mixed content message or http redirect. Then add a Strict-Transport-Security header so browsers stop trying http at all. Finish by running a security audit to confirm no insecure resources remain anywhere on the site. Repeat the checks on a phone.

Edge cases worth knowing

Keeping it fixed

  1. Keep the proxy headers in a shared snippet included by every site's server block, so new applications inherit them.
  2. Add the curl check for http addresses on your own domain to your deployment script and fail the deploy if it prints anything.
  3. Keep APP_URL, KC_HOSTNAME and similar settings in version-controlled environment templates, with https values.
  4. When you add a new layer, such as a CDN in front of an existing load balancer, retest the scheme the application sees with the test route before going live.

Run a security audit after each infrastructure change, and check redirect behaviour with the https redirects guide if visitors arriving on http are not sent straight to https.

Questions and answers

1. Why do I get mixed content errors in Laravel when my site has a valid certificate?

Because the certificate lives on the proxy, not on Laravel. The load balancer or nginx decrypts the connection and forwards plain http, so Laravel believes the visitor used http and builds every asset and route address with that scheme. A valid certificate only protects the hop between the browser and the proxy. You fix it by making the proxy send X-Forwarded-Proto and telling Laravel to trust that proxy, so it knows the original request was secure and generates https addresses.

2. Should I use URL::forceScheme or trusted proxies in Laravel?

Use trusted proxies as the main fix and forceScheme only as a backstop. Trusted proxies make Laravel understand the real request, so isSecure returns true, secure cookies behave, and the client IP address is the visitor rather than the proxy. forceScheme only rewrites generated addresses to https and leaves everything else believing the request was http. On platforms where you cannot change the proxy, forceScheme is a reasonable fallback, but combine it with an https APP_URL so background jobs agree.

3. How do I fix the Keycloak mixed content error?

Set the Keycloak hostname to its full https address and tell it to read forwarded headers from your proxy. In Keycloak 25 and later that means the hostname option with https in it, proxy-headers set to xforwarded or forwarded, and http-enabled when the proxy handles TLS. Make sure the proxy actually sends X-Forwarded-Proto. Then fetch the realm discovery document and check the issuer and every endpoint start with https, which also stops token issuer errors in your client applications.

4. What replaced the Keycloak proxy edge option?

The proxy-headers option replaced it. Older Keycloak versions used proxy set to edge, reencrypt or passthrough to describe the setup. Newer versions separate the two concerns: proxy-headers says which forwarded header format to trust, and http-enabled says whether Keycloak may listen on plain http behind the proxy. The old option was deprecated and has since been removed, so containers or scripts that still pass it can fail to start after an upgrade. Check the release notes for the exact version you run.

5. Is it safe to trust all proxies with an asterisk?

It is safe only when the application cannot be reached except through your proxy. Trusting every address means Laravel believes any X-Forwarded headers it receives. If the application port is open to the internet, someone can send a forged host header and make Laravel generate password reset or verification links pointing at their own domain. Close the port with a firewall, bind the application to a private address, or list the proxy addresses or private ranges explicitly instead of using the asterisk.

6. Does upgrade-insecure-requests fix mixed content behind a proxy?

It hides most of the browser symptoms but does not fix the cause. The header tells the browser to request http subresources on the page over https instead. It does nothing for server-side redirects that send visitors to http, links in emails, OAuth redirect addresses, or token issuers, and older clients may not honour it. Keep it as a safety net once the proxy and application are configured correctly, rather than as the only fix, or you will meet the same fault elsewhere.

7. Why does Cloudflare cause mixed content or redirect loops on my server?

Usually because the SSL mode is set to Flexible. In that mode Cloudflare accepts https from the visitor but connects to your server over plain http, so the application correctly sees an http request and builds http links, or redirects to https, which Cloudflare then fetches over http again. Install a certificate on the origin, even a free Cloudflare origin certificate, and set the mode to Full strict. The application then receives the scheme it should and the loop stops.

Find insecure http resources on your https pages

The security audit fetches your pages the way a browser does, lists every http resource, redirect and missing security header it finds, and shows which ones browsers will block.

Open Security Audit Fixes โ†’
๐Ÿ’ฌ Got a problem?
Related Guides: All Security Audit Fixes  ·  Mixed content in WordPress  ·  Mixed content and redirects  ·  HTTPS redirects  ·  HSTS missing from an https server  ·  Learning Hub

About aiwebpageseo

aiwebpageseo.com is a data-driven SEO and AEO (Answer Engine Optimisation) platform providing a free suite of technical SEO tools. Rather than relying on AI-theorised assumptions, the platform analyses live URL performance, delivering objective diagnostics, page speed metrics, CLS debugging, and site crawl data alongside actionable technical tutorials.