---
title: "Serving private token-protected media at 100ms with OpenResty + Lua"
author: "Uladzislau Marudzenka"
date: 2026-04-28T12:00:00.000Z
updated: 2026-08-04T20:47:13.000Z
canonical: https://inpvlsa.dev/posts/serving-private-token-protected-media-at-100ms-with-openresty-lua
---

# Serving private token-protected media at 100ms with OpenResty + Lua

A common requirement: serve private media from S3, fast.

"Private" here means per-user authorization. The token lives in a cookie with a 5-minute TTL, and every request has to be validated before a single byte leaves the infrastructure.

*Browser caching is its own conversation. There are safe ways to do it, and cases where it's the right call. This article is about the first-load problem: a user opens the app, lands on a products grid, and needs a dozen private thumbnails fast. That's the path I'm optimizing.*

The obvious implementation is a ***PHP*** endpoint behind ***nginx*** that validates the token, fetches the object from ***S3***, and streams it back. It works, and it's painfully slow. Around <mark class="bg-yellow-200 dark:bg-yellow-500/30">2 seconds per image</mark>, and on pages with a dozen thumbnails the UX falls apart.

Some of that is PHP-FPM overhead, some is the S3 round-trip, some is the "download fully, then stream" pattern these endpoints fall into. The bigger issue is structural. PHP sits in the hot path of every image load, doing the same token check and the same S3 fetch for assets that barely ever change.

### Why not just add a CDN?

The standard answer to "*images are slow*" is "*put a CDN in front.*" That doesn't fit here.

A CDN can vary its cache on headers or cookies, but its job is still to serve a cached response without consulting the origin. That's the whole point of it. Per-request authorization against a backend is the opposite of what a CDN is built to do. For sensitive media, the check has to happen on every delivery, not once per cache entry that the CDN later decides is safe to hand out.

So I needed three things at the same time. Authorization on every byte, not just once per cache entry. Local caching of hot images so S3 isn't in the loop on every request. And the token check itself has to be cheap enough to run inline, which means no PHP.

### Stack

The setup, since it shapes the choices: nginx as the entry point, a Symfony app for the main backend, two smaller Symfony apps handling login and proxy duties, a couple of static HTML pages for maintenance. No Next.js, no edge workers, nothing exotic.

### Iteration 1: Lua for caching, PHP still validates

The first cut moves caching to the edge but keeps authorization where it was:

```nginx
location /media/ {
    access_by_lua_block {
        local res = ngx.location.capture("/internal/validate-token")
        if res.status ~= 200 then
            return ngx.exit(ngx.HTTP_FORBIDDEN)
        end
    }
    proxy_cache media_cache;
    proxy_cache_key $uri;
    proxy_pass https://s3.internal/;
}
```

Lua calls a lightweight `/internal/validate-token` endpoint on the Symfony backend, which verifies the cookie's token and returns 200 or 403. Just a token check, without S3 call. Hot images get cached locally in OpenResty, so S3 only gets hit on a cache miss.

This lands at around 1 to 1.4 seconds per image. Better, but PHP is still in the loop on every request. The validation endpoint is cheap, and cheap PHP is still PHP-FPM, still a socket hop, still several hundred ms of overhead per image.

### Iteration 2: Validate the token in Lua

The token in the cookie is a signed value: a payload plus an HMAC-SHA256 signature over it. The backend issues the token using a shared secret, and the same secret is all you need to verify it. There's nothing special about the backend doing the verification, except that it holds the secret.

So why cant give OpenResty hold it too.

Both the backend and OpenResty read the signing secret from an environment variable. Symfony signs the token on login, OpenResty verifies it at the edge. No round-trip, no validation endpoint, no PHP in the hot path.

The validator is a small Lua module. The interesting surface is the public API, not the crypto internals:

```lua
-- media_auth.lua
local resty_sha256 = require "resty.sha256"
local resty_string = require "resty.string"
local _M = {}
local SECRET = os.getenv("MEDIA_TOKEN_SECRET")

function _M.validate(token)
    if not SECRET or SECRET == "" then
        return false, "missing_secret"
    end
    if type(token) ~= "string" or token == "" then
        return false, "empty_token"
    end
    -- Validation body omitted. It mirrors the backend's token contract
    -- and uses HMAC-SHA256 with a constant-time signature comparison.
    return true, nil
end

function _M.access()
    local token = ngx.var.cookie_media_auth
    if not token then
        return ngx.exit(ngx.HTTP_FORBIDDEN)
    end
    local ok, err = _M.validate(token)
    if not ok then
        ngx.log(ngx.WARN, "media_auth: reject (", err, ")")
        return ngx.exit(ngx.HTTP_FORBIDDEN)
    end
    -- fall through; nginx continues to proxy_cache → proxy_pass
end

return _M
```

The module is pure. No globals, no side effects at require time beyond reading the env. That makes it trivially unit-testable with a cross-language contract test that generates a token in PHP and validates it in Lua.

About the signature compare. Comparing it with plain `==` leaks timing information, since `==` short-circuits as soon as two bytes differ. Whatever comparison you use has to run in time that doesn't depend on where the strings first diverge. Edge validation isn't an excuse to skip this, and I caught myself almost skipping it the first time.

Every rejection returns a short tag (`missing_secret`, `empty_token`, `expired`, and so on) that lands in the nginx error log. When something breaks in production, you want to know why the 403 happened without attaching a debugger to the edge.

The nginx side is two lines:

```nginx
location /media/ {
    access_by_lua_file /etc/nginx/lua/media_auth_access.lua;
    proxy_cache media_cache;
    proxy_cache_key $uri;
    proxy_pass https://s3.internal/;
}
```

Where `media_auth_access.lua` is a three-line entry point that actually invokes the module:

```lua
-- media_auth_access.lua
-- OpenResty executes this file top-to-bottom and discards any return value,
-- so we must actually CALL the validator here, not just require it.
require("media_auth").access()
```

The split (module file plus entry-point file) matters. The module stays testable in isolation. The entry point is the boring glue that `access_by_lua_file` needs. Took me longer than I want to admit to figure out why `require("media_auth")` on its own did nothing.

End to end, cached images deliver in around 100ms. On cache miss, the cost is whatever S3 takes, still no PHP involved.

### Tradeoffs worth naming

Moving authorization to the edge isn't free.

The one I think about most is the revocation window. With the backend fully out of the loop, a leaked token is valid until its TTL expires. You can't kill it early. For a 5-minute TTL on media, that's an acceptable window. For payment endpoints, it would not be, and I would not do this there.

The signing secret now lives in two places, which means rotation needs an overlap period where OpenResty accepts tokens signed by either the old or the new secret, for at least the token TTL. Not hard, but you have to build it ahead of time, not discover the gap in production one night.

Any change to the PHP token format has to be mirrored in Lua and vice versa. The cheap insurance is a contract test that generates a token in PHP and validates it in Lua on every CI run. Skip it and the two implementations will drift, probably right after someone tweaks the payload.

Two smaller things. Token validation failures used to show up in Symfony logs and now show up in OpenResty logs, so the logging pipeline needs to reflect that before shipping. And if an image is deleted or replaced, the OpenResty cache needs to know, which means either a PURGE endpoint or a short cache TTL. Either way, it's a new thing to own.
