DEV Community

Cover image for How to add hotlink protection to your web fonts with Netlify Edge Functions and Deno
Sidney Alcantara
Sidney Alcantara

Posted on • Originally published at betterprogramming.pub

How to add hotlink protection to your web fonts with Netlify Edge Functions and Deno

Because the people crafting quality typefaces deserve to be paid

While working on my redesigned personal website sidney.me, I chose to use a paid font, Klim Type Foundry’s The Future Mono. It was my first time using a paid web font for a project, so I read (skimmed) the web font licence agreement. Curiously, it included this clause (emphasis mine):

3d. Web Fonts File Protection

You agree to use reasonable measures to ensure the Web Fonts are available only for the process of styling text on Your Website. At a minimum, and by way of illustration not limitation, reasonable measures include a.) preventing unlicensed third-party access, i.e. hotlinking and b.) not allowing direct download of the Fonts unrelated to the process of styling text for Your Website.

In simpler terms, you must make sure you deploy the web font files so that no one can paste the URL in their browser to download it and that other sites can’t use the web font by linking to that URL.

I’ve been using Netlify for years to host my websites, so I searched for a way to do it natively. I came across this Netlify support forum thread that unfortunately concluded with, “We don’t really have a solution for this particular problem yet, but it’s one that might be covered in the future. Stay tuned!” In March 2020.

The replies did have some suggestions, like:

  • using Netlify Functions (overkill for this use case and potentially slow) to check the Referer header in the HTTP request,
  • using a more performant Cloudflare Worker to do that check (but I don’t want to sign up for another service), or
  • serving the fonts from an S3 bucket or separate Apache server (breaking the whole point of the serverless stack) and using .htaccess.

Since the last reply on that thread, Netlify has released Edge Functions. They run JavaScript or TypeScript code using Deno, the hot new JS runtime meant to succeed Node, on CDN servers that are much closer to users than where typical serverless functions run. In other words: fast, fast, fast.

The code

Setting up Edge Functions on your Netlify project is incredibly simple. Start by creating a netlify/edge-functions directory and making a JS or TS file in that. Then, export a default function for Netlify to run. Let’s call it hotlink-protection.ts and return “Hello world” as a basic response:

// netlify/edge-functions/hotlink-protection.ts

export default () => new Response("Hello world");
Enter fullscreen mode Exit fullscreen mode

Then, we need to specify which routes this function should run on. In this case, we can limit it to font files. I only serve fonts in the WOFF2 format, as browser support is excellent. In the root of your project, create a file called netlify.toml, and set the path and the function name to be the same as the file name you chose.

# netlify.toml

[[edge_functions]]
    path = "/*.woff2"
    function = "hotlink-protection"
Enter fullscreen mode Exit fullscreen mode

While we’re here, let’s add a cache header for our fonts. They’re immutable, static assets that won’t change: we need to specify that to the browser.

# netlify.toml

[[headers]]
    for = "/*.woff2"
    [headers.values]
        Cache-Control = "public, max-age=31536000, immutable"
Enter fullscreen mode Exit fullscreen mode

Now let’s get it working. We’re effectively creating middleware that modifies and returns the response—either the font file itself or a 403 Forbidden HTTP error. Edge Functions receive two arguments, request and context. We’ll need to await the response (the font data) using context, so let’s change it to an async function. Here’s the definition in TypeScript:

import type { Context } from "https://edge.netlify.com";

export default async (
    request: Request,
    context: Context
): Promise<Response> => {
    // ...
};
Enter fullscreen mode Exit fullscreen mode

Then, let’s get the Referer header from request and use a regular expression to test that it’s coming from our website. If there’s no Referer or it doesn’t match, return a 403.

import type { Context } from "https://edge.netlify.com";

export default async (
    request: Request,
    context: Context
): Promise<Response> => {
    const referer = request.headers.get("referer");

    const regex = /^https?:\/\/(.*\.)?sidney\.me(\/.*)?$/;

    if (!referer || !regex.test(referer)) {
        return new Response("Forbidden", { status: 403 });
    }

    // ...
};
Enter fullscreen mode Exit fullscreen mode

That regex looks gnarly, but most of its special characters are used to escape the special characters / and ., which are also part of URLs. It matches sidney.me and any subdomains and directories, so make sure to change those characters (excluding the \. in between) when copy-pasting this snippet. For an explanation of the regex, check it out on regex101.com. The top-right panel highlights what each character does.

Then to return the font file as-is, we can use await context.next() to get the next HTTP response. Here’s the final code:

// netlify/edge-functions/hotlink-protection.ts

import type { Context } from "https://edge.netlify.com";

export default async (
    request: Request,
    context: Context
): Promise<Response> => {
    const referer = request.headers.get("referer");

    const regex = /^https?:\/\/(.*\.)?sidney\.me(\/.*)?$/;

    if (!referer || !regex.test(referer)) {
        return new Response("Forbidden", { status: 403 });
    }

    const response = await context.next();
    return response;
};
Enter fullscreen mode Exit fullscreen mode

Now you can commit your changes and deploy your project to Netlify as usual. Once deployed, navigating to the font URL should display “Forbidden”.

Note: if you want your fonts to work in branch deploys, you’ll need a separate check for your Netlify domain name. In my case, it’s sidney-me.netlify.app and the branch name and -- is prefixed: branch-name--sidney-me.netlify.app. Here’s my regex:

    const prodRegex = /^https?:\/\/(.*\.)?sidney\.me(\/.*)?$/;
    const devRegex = /^https?:\/\/(.*\--)?sidney-me\.netlify\.app(\/.*)?$/;

    if (
        !referer ||
        !(prodRegex.test(referer) || devRegex.test(referer))
    ) {
        return new Response("Forbidden", { status: 403 });
    }
Enter fullscreen mode Exit fullscreen mode

Subset your font files for additional protection and a performance boost

Of course, this protection itself isn’t bulletproof. Someone can easily spoof the Referer header or use DevTools to download the font files. The font data must be sent to users for their browsers to display the font. Once it’s on the user’s machine, there will always be a way for them to extract the font file.

One way to mitigate this is to subset the font files only to include the characters you use on your site, excluding characters for other languages or writing systems. Also, fonts typically have OpenType features like alternate characters, primarily for stylistic purposes, and we can follow Google Fonts’ example in removing them from the files we serve.

For my site, I used pyftsubset from FontTools to subset. Follow these instructions to install (note that you’ll need Python installed too). Here’s an example:

pyftsubset the-future-mono-regular.woff2 \
--output-file="the-future-mono-regular--subset.woff2" \
--flavor=woff2 \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
Enter fullscreen mode Exit fullscreen mode

This command subsets to the Latin character set and some punctuation symbols for a total of 385 characters. It also removes discretionary OpenType features as described in the subset docs. In my case, this more than halved the size of the font file, from 30.1 kB to 14.7 kB. This can add up and improve your Lighthouse performance scores.

Then specify the Unicode range in your CSS to help the browser use an appropriate fallback font if necessary.

@font-face {
    font-family: "The Future Mono";
    src: url("the-future-mono-regular--subset.woff2") format("woff2");
    font-display: swap;
    font-weight: 400;
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
        U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
        U+FEFF, U+FFFD;
}
Enter fullscreen mode Exit fullscreen mode

Serve fonts from a different project

If you’re publishing your site’s code as open-source, make sure not to include your font files in that repo. You don’t want to be part of the problem of GitHub becoming the web’s largest font piracy site.

  1. Add your font files to your .gitignore file with *.woff2.
  2. If you have already publicly published the repo, permanently remove the files from history (instructions).
  3. Then, deploy your font files in a new private repository as a separate Netlify project.
  4. You can keep the files in your local repository for testing and leave your CSS unchanged, pointing to files in the same domain name. Then use a rewrite to point to the other project’s domain name like so:

    # netlify.toml
    
    [[redirects]]
      from = "/fonts/the-future-mono-*"
      to = "https://your-project.netlify.app/the-future-mono/the-future-mono-:splat"
      status = 200
      force = true
    

Important: you must have the hotlink protection Edge Function on both projects for the matching URLs. If someone goes to your primary URL to get the font, the Edge Function on the private project will get the correct Referer header and let anyone download or hotlink it.

You can copy the same function into your primary repository and set it to the appropriate paths in netlify.toml:

# netlify.toml

[[edge_functions]]
  path = "/fonts/the-future-mono-*"
  function = "hotlink-protection"
Enter fullscreen mode Exit fullscreen mode

Now you have the best of both worlds: the font is available for local development while protected when deployed.

Additional resources

  • Here are some suggestions from TypeKit on additional protections for web fonts.
  • Alternatively, you can use glyphhanger to subset web fonts based on what characters are used on your website.
  • You can see which characters are in a Unicode range and do maths on ranges using Unicode Range Interchange.
  • You can see which OpenType features and all the characters are included in your font file using Wakamai Fondue.
  • If you’re curious about web font performance, Zach Leatherman wrote an excellent guide on its implementation on CSS-Tricks.

And, of course, check out my personal site, sidney.me, and let me know what you think on Mastodon, @notsidney@indieweb.social.

Hi, I’m Sidney.

Top comments (1)

Collapse
 
ascorbic profile image
Matt Kane

Nice post! btw you don't need to do this:

    const response = await context.next();
    return response;
Enter fullscreen mode Exit fullscreen mode

It's more efficient to just do an empty return, or not return at all.