Securing static resources with cookies, nginx, and Lua

This is an old post!

This post is over 2 years old. Solutions referenced in this article may no longer be valid. Please consider this when utilizing any information referenced here.

I’ve been working with one of my clients the last month on migrating his iron- based architecture to a cloud-based provider. In this transition, we are going from one or two physical servers to multiple cloud servers and separating out parts to better scale each individual service.

As part of this, we are moving a significant library of images and videos away from being served off the same web server as the application and to a server tuned to handle requests for these static assets. The problem is that a lot of these assets (the videos and full-size images) are for paying members only. We need a way to secure those resources across physical servers.

My first inclination was to use the secure URL functionality in nginx. But this is sub-optimal for a few reasons. The big one is that it generates a unique URL for each requests, which completely negates any browser caching for subsequent requests. It also requires you to either generate all the URLs at page time, or use redirects.

Secure URLs work great if you have to make a secure requests across different domains. But, from the browser’s point of view, we will be under the same domain. Instead of the assets will be stored under But we’re still under the domain. So that gives us another option: cookies. We can set a some cookies on login, and use nginx and Lua to verify the cookie signature on the other server before serving a static asset.

The first thing we need to do is set up a simple algorithm that determines what our bounds are for serving an asset. An example would be three pieces of information:

  1. A user identifier.

  2. An expiration time.

  3. A secret token that is shared between both the application server and the asset server.

So you might do something like this (in pseudocode):

var secret_token = "your secret token here";
var expire = time() + 3600; // Expire in 1 hour.
var asset_hash = md5(secret_token + user_id + expiration_time);

setcookie("user_id", user_id, expire, "/", "");
setcookie("expire", expire, expire, "/", "");
setcookie("asset_hash", asset_hash, expire, "/", "");

So what we’re doing here is setting three cookies: the user_id, the expiration timestamp, and the asset hash, which is an md5 hash of the three pieces of information, only two of which are also set as cookies. The third piece of information, the secret token, is only known on the servers.

Quick note here, I’m using md5 because it’s fast, but you can use any hashing algorithm you’d like as long as you do it the same in both places. md5 is insecure, but this method should be sufficient to stop all but the most determined adversary who would try to take the hash against a rainbow table.

So, now we have these cookies set on our application server, we need to jump over to our asset server and make some changes. This is where Lua comes into play.

nginx is a very stripped down HTTP server that is very fast. But, nicely, it provides you with the ability to implement some things within nginx using Lua. And we can use this functionality to verify our cookies before serving an asset.

--- Returns an error page
function throw_error(error)
    local f ="/usr/share/nginx/html/" .. error .. ".html", "rb")
    local content = f:read("*all")

    ngx.header.content_type = 'text/html';
    ngx.status = error

local secret = "you secret token here"

if ngx.var.cookie_user_id == nil or ngx.var.cookie_expire == nil or ngx.var.cookie_asset_hash == nil then
    ngx.log(ngx.INFO, "Throwing 410 because of missing cookies.")

local cookie_user_id = ngx.var.cookie_user_id
local cookie_expire = ngx.var.cookie_expire
local cookie_asset_hash = ngx.var.cookie_asset_hash

local testhash = ngx.md5(secret .. cookie_user_id .. cookie_expire)

if testhash ~= cookie_asset_hash then
    ngx.log(ngx.INFO, "Throwing 410 because of a bad hash. Has was " .. hash .. " expected " .. testhash)

if tonumber(cookie_expire) > 0 and ngx.time() > tonumber(cookie_expire) then
    ngx.log(ngx.INFO, "Throwing 410 because of timestamps.")

Notice what we’re reading here. ngx is provided by the nginx Lua machine, and cookies are available using ngx.var.cookie_<cookie_name>. So we first check that all the cookies are present, then we check if the hash is valid, then we check if the expire time has passed. If any of these conditions fail, we exit by setting the proper responses on the ngx object.

So the last thing to do is to tell nginx to run this script before serving any protected assets. We do that by using the access_by_lua_file nginx directive:

location /attachments/fullsize {
    access_by_lua_file /etc/nginx/lua/access_asset.lua;
    expire 1h;
    add_header Cache-Control public;
    add_header Vary Accept-Encoding;
    root /var/media/;

Now, if the user edits any of those cookies using the browser console, the computed hash won’t match and nginx won’t serve the asset. And they can’t recompute the hash on their own because they lack the secret token. But from the browser’s perspective, it’s just a standard HTTP request with the appropriate caching headers. So it will happily cache the asset for the specified period of time, thus reducing subsequent requests.

And, as an added bonus, the URL cannot be shared at all with non-members. With “secure URLs” that use a hash as part of the URL, for as long as that URL is valid anyone can use it. This method requres the approprate cookies on the request and anyone wanting to share the URL would have to have either create a page that sets the appropriate cookies before redirecting a user, or have the user manually enter the cookies.

Comments (0)

Interested in why you can't leave comments on my blog? Read the article about why comments are uniquely terrible and need to die. If you are still interested in commenting on this article, feel free to reach out to me directly and/or share it on social media.

Contact Me
Share It
Hammerspoon is a pretty nifty tool. It’s kind of difficult to explain what it does, but the best I can do is that it allows you to use Lua to script actions on your Mac and, crucially, respond to events. For instance, I use Hammerspoon to lauch all my applications when I get to work and lay them out on the screen in the order that I like. I can do this because I was able to attach a location listener to work’s location, and execute Lua code on arrival. The amount of things that you can do with this tool is pretty stunning. It’s become an indespensible part of my macOS experience.
Read More
So I have this older Dell laser printer, a B1160w. It was released back in 2012, but it is a totally fine home printer for when I occasionally need to print something and it still works great after all these years, so I see no compelling reason to buy a new one. But there’s a problem: macOS support. Namely, no drivers have been released for macOS since 2017. Starting with Catalina, Apple started requiring code signing for executables, and the official Dell driver has an executable in it that refuses to execute because it isn’t signed. And despite my best efforts, short of turning off Gatekeeper entirely, I was not able to get it to work. But the printer itself is fine; there is absolutely no reason to create additional electronic waste purely for software reasons. But thanks to open-source software, we have another options: CUPS.
Read More
So my long march away from Apache has been coming to an end, and I am finally migrating some of the more esoteric parts of my Apache setup to nginx. I have a side domain that I use to share files with some friends and, for ease of use, I have configured it with WebDAV so that they can simply mount it using Finder or Explorer, just like a shared drive. The problem? nginx’s WebDAV support … sucks. First, the ngx_http_dav_module module is not included in most distributions from the package managers. Even the ones that are, it’s usually pretty out of date. And, perhaps worst of all, it is a partial implementation of WebDAV. It doesn’t support some of the things (PROPFIND, OPTIONS, LOCK, and UNLOCK) that are needed to work with modern clients. So what can we do?
Read More