None

How to Serve Favicons in Django from Cloud Storage

Discover how to proxy serve favicons in Django from cloud storage like AWS S3, DigitalOcean Spaces, and Google Cloud Storage Buckets to mitigate CORS issues related to external storage.

Background

A "favicon" is a small icon associated with a particular URL that's displayed in locations like a browser's address bar, browser tab, bookmark list, search snippets and more. Favicons improve brand recognition and help attribute content. With Django, serving these important little files can be a bit tricky if you're using cloud storage.

This discussion will look at a couple of ways to get your favicon working if you encounter cross-origin resource sharing ("CORS") errors.

Problem

If you're serving files from an external cloud bucket, you'll likely want to store your favicon files along with other static files. A single approach to handling static files will reduce project's complexity and make it easier to maintain. In Django, we're able to include a favicon just like any other static file.

<!-- within the head tag -->

<link rel="icon" type="image/png" sizes="32x32" href="{% static 'favicon/favicon-32x32.png' %}" />

However, you may find that your browser refuses to load the file with this configuration alone.

// browser console

[Error] Origin https://example.com is not allowed by Access-Control-Allow-Origin. Status code: 200

This is because of the same-origin policy ("SOP") for web browsers. The SOP is a:

...critical security mechanism that restricts how a document or script loaded by one origin can interact with a resource from another origin.

mdn web docs

The SOP exists to protect you and the resource from malicious sites that contain scripts attempting to access something in a suspicious way. The resource isn't the concern. The site is what's being interpreted by the browser as being naughty. It's up to the resource to communicate that the particular site is allowed to access it if it exists at a different origin than the response.

What constitutes a different origin?

Two URLs have the same origin if the protocol, port (if specified), and host are the same for both.

mdn web docs

Generally, link and script tags allow reads from different origins. Stylesheets and JavaScript files are usually read from cloud storage buckets without a problem. However, you might run into problems with <link rel="icon" ... /> or <link rel="manifest" ... />.

Use The Bucket

Can't I just use the bucket? Sure, don't yell. If you still wan't to use the cloud storage solution to deliver your favicon directly this is possible if you're able to set the appropriate headers. Different cloud providers give users different degrees of control so you'll have to consult the docs of your specific provider to see how to set headers.

Specifically, the headers we're concerned with are Content-type and Access-Control-Allow-Origin.

The former "Content-Type," or otherwise known as the MIME type ("Multipurpose Internet Main Extension"), may or may not be set automatically. It probably is. Many cloud providers automatically detect this and set it without you needing to do anything.

If you're running into CORS problems, inspect to make sure that the value is set correctly. For the .png files, the header value is "image/png." The .ico files have the header value "image/vnd.microsoft.icon." And last, you'll likely have a manifest file that helps browsers understand when and where to use a particular icon variant. This value should be "application/manifest+json."

Next and more importantly, you need to set "Access-Control-Allow-Origin." This will NOT be set automatically. For DigitalOcean, you have to set allowed origins bucket-wide. Either specify your specific origin (remember to match protocol, host and port), or drop the trusty "*" wildcard for the "origins" value. I prefer to be explicit.

You'll probably need to specify allowed methods ("GET," "PUT," "POST," and so on). We only need "GET" request for favicons so only allow this method. When setting policies, we always want to establish the most restrictive policy.

With the Content-Type and Access-Control-Allow-Origin headers set, you'll probably have a good read on the favicon files. But, we can go a little bit further to ensure things are working properly.

To ensure the request for the externally stored favicon uses CORS headers, we should set the crossorigin attribute on the link tag to "anonymous." In addition to CORS headers, crossorigin="anonymous" tells the browser not to send user credentials unless the resource is located at the same origin.

 

The "crossorigin" attribute isn't supported for 'rel="icon"' in Chromium-based browsers.

With this implementation, you should be good to go using your external cloud storage to serve favicons. Next, we'll look at an alternative approach that simply proxy serves the resource.

Proxy Favicons

The problems we're encountering with favicons revolves around the browser SOP as discussed earlier. The browser thinks the site is behaving suspiciously when requesting certain resources from places other than its origin. So why not serve the favicon from the same origin? Let's give that a whirl.

In order to proxy serve the favicon files through the Django site's origin, we'll need to create a URL pattern we can use in our templates (replacing the Django static tag) and create a simple view that retrieves the resource from the bucket before delivering it to the browser.

URL Pattern

The URL pattern we'll use will accept as an argument the name of the file so we know what to request from the external bucket. As long as all your favicon-related resources (.png, .ico, .webmanifest files, etc.) are located in the same directory, this is all that's needed to retrieve the resource.

To add a little specificity, I'll prefix the location with "favicon/." Although, I find it highly unlikely collision will occur with these routes. Next, I'll pass the view function proxy_favicon which we'll create in the next step. And last, I'll name the pattern "proxy_favicon."

# urls.py

from django.urls import path


urlpatterns = [
    # favicon
    path("favicon/<str:filename>", proxy_favicon, name="proxy_favicon"),
]

View Function

The view function needs to accomplish a couple of tasks for us. First, the view function needs to capture the argument being passed by the URL pattern.

# views.py


def proxy_favicon(_request, filename):
    pass

I'm prefixing the request object with an underscore so my IDE doesn't complain about an unused argument. View functions always receive the request as the first positional argument so we need to include it. However in this case, we're not going to use it.

Next, I want to make sure that the filename passed to the view actually points to an existing resource. Rather than hit the external database, I'm using a dictionary that maps to the physical resources. If it's not in the dictionary, it shouldn't be in the bucket.

# views.py

from django.http import Http404

# or however your's is structured
BUCKET_LOCATION = "https://<bucket-name>.<region>.digitaloceanspaces.com/"

FAVICON_MAP = {
    "favicon.ico": f"{BUCKET_LOCATION}/favicon/favicon.ico",
    "favicon-16x16.png": f"{BUCKET_LOCATION}/favicon/favicon-16x16.png",
    "favicon-32x32.png": f"{BUCKET_LOCATION}/favicon/favicon-32x32.png",
    "android-chrome-192x192.png": f"{BUCKET_LOCATION}/favicon/android-chrome-192x192.png",
    "android-chrome-512x512.png": f"{BUCKET_LOCATION}/favicon/android-chrome-512x512.png",
    "apple-touch-icon.png": f"{BUCKET_LOCATION}/favicon/apple-touch-icon.png",
    "site.webmanifest": f"{BUCKET_LOCATION}/favicon/site.webmanifest",
}


def proxy_favicon(_request, filename):
    if filename not in FAVICON_MAP:
        raise Http404("Favicon not found")

Next, we retrieve the file from the bucket using the URL contained in FAVICON_MAP.

# views.py

import requests

from django.http import Http404

...

def proxy_favicon(_request, filename):
    ...
    remote_url = FAVICON_MAP[filename]

    try:
        resp = requests.get(remote_url, timeout=10)
        resp.raise_for_status()
    except requests.RequestException:
        raise Http404("Error fetching remote file")

I'm using the Python requests library. If the response object doesn't contain a good status code, then the call to raise_for_status() will raise an HTTPError. Note, the HTTPError isn't what the try/except block is catching. RequestException isn't an ancestor to HTTPError but rather a separate exception that's raised for ambiguous problems. I'm catching it and then raising a different exception I find to be more descriptive.

Next, I select a content type to provide with the response we'll deliver to the browser. Favicon bundles often contain resources requiring different content types. To accommodate this, I store those in a dictionary and map them to the file extension.

# views.py

...

CONTENT_TYPES = {
    ".ico": "image/x-icon",
    ".png": "image/png",
    ".webmanifest": "application/manifest+json",
}

def proxy_favicon(_request, filename):
    ...

    ext = '.' + filename.split('.')[-1]
    content_type = CONTENT_TYPES.get(ext, 'application/octet-stream')

If I can't retrieve a content type from my dictionary (this shouldn't happen), then I use "application/octet-stream" as a fallback for unknown types.

All that's left to do know is build a response object containing the content type we just established and send it to the browser. Additionally, I'll decorate this view function with @request_GET to add a little protection and @cache_page for performance given this resource is unlikely to change often.

# views.py

from django.http import HttpResponse
from django.views.decorators.cache import cache_page
from django.views.decorators.http import require_GET

...


@require_GET
@cache_page(60 * 60 * 24)
def proxy_favicon(_request, filename):
    ...
    return HttpResponse(resp.content, content_type=content_type)

The page will be cached for one day (60 seconds * 60 minutes * 24 hrs).

Here's the complete view:

# views.py

import requests

from django.http import HttpResponse, Http404
from django.views.decorators.cache import cache_page
from django.views.decorators.http import require_GET

BUCKET_LOCATION = "https://<bucket-name>.<region>.digitaloceanspaces.com/"

FAVICON_MAP = {
    "favicon.ico": f"{BUCKET_LOCATION}/favicon/favicon.ico",
    "favicon-16x16.png": f"{BUCKET_LOCATION}/favicon/favicon-16x16.png",
    "favicon-32x32.png": f"{BUCKET_LOCATION}/favicon/favicon-32x32.png",
    "android-chrome-192x192.png": f"{BUCKET_LOCATION}/favicon/android-chrome-192x192.png",
    "android-chrome-512x512.png": f"{BUCKET_LOCATION}/favicon/android-chrome-512x512.png",
    "apple-touch-icon.png": f"{BUCKET_LOCATION}/favicon/apple-touch-icon.png",
    "site.webmanifest": f"{BUCKET_LOCATION}/favicon/site.webmanifest",
}

CONTENT_TYPES = {
    ".ico": "image/x-icon",
    ".png": "image/png",
    ".webmanifest": "application/manifest+json",
}

@require_GET
@cache_page(60 * 60 * 24)
def proxy_favicon(_request, filename):
    if filename not in FAVICON_MAP:
        raise Http404("Favicon not found")

    remote_url = FAVICON_MAP[filename]

    try:
        resp = requests.get(remote_url, timeout=10)
        resp.raise_for_status()
    except requests.RequestException:
        raise Http404("Error fetching remote file")

    ext = '.' + filename.split('.')[-1]
    content_type = CONTENT_TYPES.get(ext, 'application/octet-stream')

    return HttpResponse(resp.content, content_type=content_type)

With this implementation, the favicon files will be served from the same origin as the site completely avoiding any CORS issues.

Final Thoughts

Which solution is better? Different strokes for different folks, amigo. If your cloud provider makes working with headers and origins easy, then maybe stick to the first approach. If you want a more robust solution that definitely circumvents CORS issues, then implement the proxy solution. Whatever direction you decide to go in, this discussion gives you a couple options to make sure you're able to get what you need to where you need it without tripping over browser security mechanisms.

Details
Published
June 16, 2025
Next
June 25, 2025

How To Resize Images On Upload in Django

Resize uploaded images during Django form validation using Pillow to prevent large files from reaching your storage system.