Troubleshooting
HTTPS, TLS & DNS

Fix Mixed Content Warnings on a Django HTTPS Site

Fix mixed content on a Django site served over HTTPS: find http:// resources, correct absolute URLs behind a proxy, fix storage and CDN URLs, rewrite stored content, and add upgrade-insecure-requests.

Problem statement

The site loads over HTTPS, but the browser's padlock shows a warning, images are missing, styles or scripts do not load, or forms warn that they are not secure. The browser console shows:

Mixed Content: The page at 'https://example.com/blog/' was loaded over HTTPS,
but requested an insecure image 'http://example.com/media/uploads/header.jpg'.
This request has been automatically upgraded to HTTPS.

Mixed Content: The page at 'https://example.com/' was loaded over HTTPS,
but requested an insecure script 'http://cdn.example.net/widget.js'. This request has been blocked.

Browsers upgrade some insecure subresources (images, audio, video) automatically and block others (scripts, stylesheets, iframes, fetch requests). Either way, something in the page still produces http:// URLs. In Django projects the source is usually one of four places: Django itself building absolute URLs as http behind a proxy, storage or CDN settings, hardcoded URLs in templates, or http URLs saved in the database.

Sources of http:// URLsProxy headersbuild_absolute_uriStorage settingsmedia and CDN URLsTemplateshardcoded linksStored contentrich text, JSONFind which source produced the URL; the fix differs for generated and stored URLs.
  1. List insecure URLs in the rendered page: curl -s https://example.com/page/ | grep -o 'http://[^"'"'"' )>]*' | sort -u.
  2. If Django-generated absolute URLs start with http://, set SECURE_PROXY_SSL_HEADER and make the proxy send X-Forwarded-Proto.
  3. Make STATIC_URL, MEDIA_URL, and storage custom domains protocol-free or https://.
  4. Replace hardcoded http:// links in templates with {% static %}, {% url %}, or https://.
  5. Rewrite http:// URLs stored in the database with a data migration.
  6. Add upgrade-insecure-requests to your Content Security Policy as a safety net, not a replacement for the fixes.

Step-by-step solution

1. Find every insecure URL

curl -s https://example.com/blog/ | grep -o 'http://[^"'"'"' )>]*' | sort -u
where http:// URLs come fromURLs Django buildsbuild_absolute_uri behind a proxyStatic, media, storageSTATIC_URL or custom_domain withhttpTemplateshard-coded http:// links andembedsStored contentrich text and URLs saved in the DBEach source needs a different fixdjango-deployment.com
The browser console lists every blocked or upgraded request.

Also open the browser's developer tools on key pages: the console lists blocked and upgraded requests, and the Network panel shows their initiators. Check pages with user content (blog posts, product descriptions), not just the homepage.

Group the URLs by origin: your own domain, your media or CDN domain, and third-party domains.

2. Fix URLs that Django generates

request.build_absolute_uri(), request.is_secure(), and anything built on them (pagination links in APIs, canonical URLs, social meta tags, OAuth redirect URIs, sitemaps built from requests) depend on Django knowing the original request was HTTPS. Behind Nginx or a load balancer, Gunicorn receives plain HTTP, so Django thinks the scheme is http unless told otherwise:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
teach Django it is on HTTPSSECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")USE_X_FORWARDED_HOST =Falserequest.build_absolute_uri("/feed/")  # →https://example.com/feed/1231The proxy must always set this header.2Keep off unless the proxy rewrites Host.3Now built with the right scheme.Sitemaps, feeds, and emails all use the same scheme detectiondjango-deployment.com
Emails built outside a request need an explicit scheme or SITE_URL setting.
proxy_set_header X-Forwarded-Proto $scheme;

Set the header only in a proxy that overwrites any client-supplied value, and only trust it when Django is reachable exclusively through that proxy. Check the result:

python manage.py shell -c "
from django.test import RequestFactory
r = RequestFactory().get('/', HTTP_X_FORWARDED_PROTO='https', HTTP_HOST='example.com')
print(r.is_secure(), r.build_absolute_uri())
"

Django REST Framework's paginated next/previous links and hyperlinked serializers use build_absolute_uri, so this one setting fixes them too.

3. Fix static, media, and storage URLs

STATIC_URL = "/static/"
MEDIA_URL = "/media/"

Relative URLs inherit the page's scheme. If you use a CDN or separate media domain, use https:// explicitly:

STATIC_URL = "https://cdn.example.com/static/"

With django-storages on S3, AWS_S3_CUSTOM_DOMAIN produces URLs with the protocol from AWS_S3_URL_PROTOCOL, which defaults to https:. Check that it has not been set to http:. For other storage backends, look for similar protocol or base_url settings. The CDN or bucket domain must also serve HTTPS with a valid certificate.

4. Fix templates

grep -rn "http://" templates/ */templates/ | grep -v "http://www.w3.org"

Replace:

  • links to your own site with {% url %} or relative paths,
  • static assets with {% static %},
  • third-party scripts and fonts with their https:// URLs (practically every provider supports HTTPS; drop any that do not).

xmlns="http://www.w3.org/2000/svg" and similar namespace identifiers are not requests and do not cause mixed content.

5. Rewrite stored URLs

Content created before the site moved to HTTPS often contains http://example.com/media/... in rich text fields, Markdown, JSON fields, or CMS blocks. Rewrite it with a data migration:

from django.db import migrations
from django.db.models import Value
from django.db.models.functions import Replace


def forwards(apps, schema_editor):
    Post = apps.get_model("blog", "Post")
    Post.objects.filter(body__contains="http://example.com/").update(
        body=Replace("body", Value("http://example.com/"), Value("https://example.com/"))
    )


class Migration(migrations.Migration):
    dependencies = [("blog", "0017_previous")]
    operations = [migrations.RunPython(forwards, migrations.RunPython.noop)]

Back up first, restrict the replacement to your own domains, and repeat for each model and field that stores HTML or URLs. For external http:// links inside content (links users click, not resources the page loads), no rewrite is required; only embedded resources cause mixed content.

6. Add a safety net

A Content Security Policy directive tells browsers to upgrade every insecure request on your pages:

Content-Security-Policy: upgrade-insecure-requests
safety nets, not fixesEffectCSP upgrade-insecure-requests✓rewrites http subresources to httpsHSTS✓forces https for your own hostblock-all-mixed-content!deprecated: do not rely on itFix the URLs at the source; the headers catch what slips throughdjango-deployment.com
upgrade-insecure-requests cannot help if the https URL does not exist.

The simplest way is at the proxy:

add_header Content-Security-Policy "upgrade-insecure-requests" always;

If you already send a full policy from Django (built-in CSP support in Django 6.0 and newer, or the django-csp package on older versions), add the directive there instead, because two separate CSP headers are both enforced. The directive hides remaining problems from users, so still fix the sources; resources on hosts without HTTPS will fail instead of loading.

Verification check:

for p in / /blog/ /products/; do
  echo "$p: $(curl -s https://example.com$p | grep -c 'src="http://\|href="http://[^"]*\.\(css\|js\)')"
done

Expect 0 for each page, no mixed content messages in the browser console, and a clean padlock.

Explanation

A page loaded over HTTPS promises the browser that everything on it was delivered securely. Any subresource fetched over HTTP breaks that promise, because it can be read or modified in transit; a modified script can take over the page. Browsers therefore block active content and upgrade or flag passive content. Django-generated URLs follow what Django believes the request scheme to be, which is why the proxy header setting fixes a whole class of issues at once, while stored content and templates need one-off cleanup.

Edge cases / notes

HSTS

Once everything loads over HTTPS, enable HSTS (SECURE_HSTS_SECONDS) so browsers never try HTTP for your domain again. Start with a short value and increase it after verifying.

Emails and external consumers

Absolute URLs in emails, feeds, and webhooks are generated the same way. Check them after fixing the proxy header, especially password reset links.

When to automate this

Add a crawl step to CI or post-deploy checks that fetches key pages and fails on src="http:// or href="http://...css|js". Content editors can reintroduce insecure embeds, so a scheduled check catches regressions.

Next steps

FAQ

What causes mixed content on a Django site?

Resources referenced with http:// URLs: Django building absolute URLs as HTTP behind a proxy, storage or CDN settings, hardcoded template links, or old content in the database.

Why does build_absolute_uri return http behind Nginx?

Gunicorn receives HTTP from Nginx. Set SECURE_PROXY_SSL_HEADER and forward X-Forwarded-Proto from the proxy.

Does upgrade-insecure-requests fix mixed content?

It makes browsers request resources over HTTPS, which works if those hosts support HTTPS. Fix the underlying URLs as well.

No. Only resources the page loads (images, scripts, styles, iframes, requests) cause mixed content. Plain links to other sites do not.

Keep going