luaguides

Routing and Middleware in OpenResty

How OpenResty Processes a Request

Before you can understand routing and middleware, you need to understand what happens when an HTTP request arrives at your OpenResty server. Think of OpenResty as a factory assembly line. A request walks in the door, gets handed off from one station to the next, and eventually a response rolls out the other end. Each station has a specific job. Some stations inspect the request, some modify it, and some generate the response. The order of stations is fixed, and a request must pass through each one.

In OpenResty, these stations are called phases. Nginx (the web server that OpenResty extends) defines a fixed pipeline of phases that every request passes through. Each phase has a specific purpose, and you can drop in Lua code at most of these phases to do custom work.

The phases execute in this order:

PhaseWhat happens here
server_rewriteEarliest point where Lua can run. Good for server-wide rewrites.
rewriteURL rewriting and initial routing logic.
accessAuthentication and authorization checks.
precontentRuns right before the content phase. Useful for preprocessing.
contentGenerates the actual response. This is where your app lives.
logLogging and cleanup after the response is sent.

There are a few more phases (like post-read and preaccess), but these are the ones you will work with most often. The key thing to remember is: phases run in a fixed order. You cannot skip ahead or go backwards. Once a request leaves the rewrite phase, it will never come back to it.

The Rewrite Phase: Your First Router

The rewrite phase is where you typically implement your routing logic. Routing means deciding “what should handle this request?” Should it go to your Lua code? Should it be redirected somewhere else? Should the URL be rewritten before anything else looks at it?

In OpenResty, you use the rewrite_by_lua_block directive to run Lua code during the rewrite phase. Here is the simplest possible example:

location / {
    rewrite_by_lua_block {
        -- This runs for every request to /
        ngx.say("Hello from the rewrite phase!")
        ngx.exit(ngx.HTTP_OK)
    }
}

That example is too simple to be useful, so let’s build something that actually routes. Suppose you have two endpoints: /api/users and /api/products. You want Lua to decide which one handles the request.

location /api {
    rewrite_by_lua_block {
        -- ngx.var.uri holds the requested path, without the query string
        local uri = ngx.var.uri

        if uri == "/api/users" then
            ngx.exit(ngx.OK)
            -- Falling through to content_by_lua below

        elseif uri == "/api/products" then
            ngx.exit(ngx.OK)
            -- Falling through to content_by_lua below

        else
            -- No matching route — return 404
            ngx.exit(ngx.HTTP_NOT_FOUND)
        end
    }

    -- This location acts as a fallback; the actual routing logic
    -- above decides whether we get here or not.
    content_by_lua_block {
        ngx.say("You reached the content phase for: " .. ngx.var.uri)
    }
}

A few things to notice here. First, ngx.var.uri is how you read Nginx variables. In this case it gives you the path part of the URL. Second, calling ngx.exit(ngx.OK) does not terminate the request — it just exits the rewrite phase and lets the request continue to the next phase. To actually stop a request and send a response, you pass an HTTP status code like ngx.HTTP_NOT_FOUND or ngx.HTTP_INTERNAL_SERVER_ERROR to ngx.exit.

The Access Phase: Middleware for Authentication

Once the rewrite phase has decided where a request should go, the access phase runs. This is the ideal place for middleware — code that runs before your main application logic and checks something about the request. Authentication is the classic example: you check an API key, validate a session token, or verify a username and password.

Here is a simple API key check implemented in the access phase:

location /api {
    access_by_lua_block {
        -- Read the API key from the request headers
        local api_key = ngx.req.get_headers()["X-API-Key"]
        -- NOTE: In production, load this from a secrets manager or env var
        local expected_key = os.getenv("API_KEY") or "DEMO_KEY"
        if not api_key or api_key ~= expected_key then
            -- Set a 401 response and terminate the request
            ngx.exit(ngx.HTTP_UNAUTHORIZED)
        end
        -- If the key is correct, the request continues to the next phase
    }

    content_by_lua_block {
        ngx.say("You have access to the API!")
    }
}

The critical difference from rewrite_by_lua_block is that returning a non-OK status from access_by_lua_block terminates the request immediately. In the example above, if the API key is wrong, ngx.exit(ngx.HTTP_UNAUTHORIZED) sends a 401 response and stops the request right there. The content_by_lua_block never runs.

This is what makes the access phase so useful for middleware: you can validate credentials in one place, and if validation fails, the request never reaches your application code. Your content handler stays clean and focused on business logic.

You can also chain multiple middleware steps by stacking access_by_lua_block directives. Each one runs in sequence, and if any one of them returns an error status, the chain breaks:

http {
    -- Declare a shared memory zone for rate limiting
    -- Must be declared in the http block before use
    lua_shared_dict ip_limit 10m;

    server {
        listen 8080;

        location /api {
            -- First: check API key
            access_by_lua_block {
                local key = ngx.req.get_headers()["X-API-Key"]
                -- NOTE: In production, load this from a secrets manager or env var
                local expected_key = os.getenv("API_KEY") or "DEMO_KEY"
                if not key or key ~= expected_key then
                    ngx.exit(ngx.HTTP_UNAUTHORIZED)
                end
            }

            -- Second: check rate limit
            access_by_lua_block {
                -- NOTE: ip_limit must be declared with lua_shared_dict in the http block
                local shared = ngx.shared.ip_limit
                local key = ngx.var.remote_addr
                local count, err = shared:incr(key, 1)
                if not count then
                    -- First request from this IP, initialize counter
                    shared:set(key, 1, 60)  -- 1 request per 60 seconds
                elseif count > 10 then
                    ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
                end
            }

            content_by_lua_block {
                ngx.say("Request approved!")
            }
        }
    }
}

This pattern of running multiple access-phase handlers in sequence is how you build middleware stacks in OpenResty — similar in spirit to middleware in Express.js or Django.

The Content Phase: Generating Responses

The content phase is where the actual response gets generated. This is what a user actually sees. In OpenResty, you use content_by_lua_block (or content_by_lua_file for larger scripts) to write your response logic.

One important gotcha: when you use content_by_lua_block, it completely replaces the normal Nginx content handler. That means directives like root (which tells Nginx where to find static files) and proxy_pass (which forwards requests to another server) are ignored. Your Lua code is responsible for the entire response.

location /hello {
    content_by_lua_block {
        ngx.header["Content-Type"] = "text/plain"
        ngx.say("Hello, world!")
    }
}

Here, ngx.header lets you set response headers, and ngx.say writes output followed by a newline (similar to print in many languages). ngx.print does the same thing but without the newline.

Routing by HTTP Method

Real APIs need to handle the same URL differently depending on the HTTP verb. GET /users might return a list, while POST /users creates a new entry. Use ngx.req.get_method() to inspect the request method:

location /users {
    content_by_lua_block {
        local method = ngx.req.get_method()
        local uri = ngx.var.uri

        if method == "GET" then
            ngx.header["Content-Type"] = "application/json"
            ngx.say('{"users": ["alice", "bob", "carol"]}')

        elseif method == "POST" then
            ngx.header["Content-Type"] = "application/json"
            ngx.say('{"message": "User created", "uri": "' .. uri .. '"}')

        else
            ngx.exit(ngx.HTTP_METHOD_NOT_ALLOWED)
        end
    }
}

Pattern-Based Routing

For URLs with dynamic segments like /api/users/42 or /api/products/some-slug, you need pattern matching. Use string.match with Lua patterns (or ngx.re.match for regex):

location /api {
    content_by_lua_block {
        local uri = ngx.var.uri

        -- Match /api/users/<id> using a Lua pattern
        local user_id = string.match(uri, "^/api/users/([%w]+)$")
        if user_id then
            ngx.header["Content-Type"] = "application/json"
            ngx.say('{"user_id": "' .. user_id .. '"}')
            return
        end

        -- Match /api/products/<slug>
        local slug = string.match(uri, "^/api/products/([%w-]+)$")
        if slug then
            ngx.header["Content-Type"] = "application/json"
            ngx.say('{"product": "' .. slug .. '"}')
            return
        end

        ngx.exit(ngx.HTTP_NOT_FOUND)
    }
}

The pattern [%w]+ matches one or more word characters (letters, digits, underscores). If you need more complex matching, ngx.re.match supports full Perl-compatible regex:

        -- ngx.re.match for more complex patterns
        local m, err = ngx.re.match(uri, "^/blog/(\\d{4})/(\\d{2})/([a-z-]+)$")
        if m then
            ngx.say("Year: " .. m[1] .. ", Month: " .. m[2] .. ", Slug: " .. m[3])
        else
            ngx.exit(ngx.HTTP_NOT_FOUND)
        end

Using Internal Subrequests

If you want to serve static files from within Lua, you can use ngx.location.capture to ask Nginx to serve them. This creates an internal subrequest — the client never sees it, but Lua can fetch the output of another location as if it were an HTTP response:

location /static {
    alias /var/www/static/;
}

location / {
    content_by_lua_block {
        local path = ngx.var.uri
        if string.match(path, "^/static/") then
            -- Forward to Nginx's static file handler
            local res = ngx.location.capture(ngx.var.request_uri)
            ngx.header["Content-Type"] = res.header["Content-Type"]
            ngx.print(res.body)
        else
            ngx.exit(ngx.HTTP_NOT_FOUND)
        end
    }
}

Note that ngx.location.capture cannot be used with locations that themselves use add_before_body, add_after_body, auth_request, or echo_location directives — those are subrequest-generating directives and cannot be captured re-entrantly.

Setting Response Headers with header_filter_by_lua_block

If you want to set headers on every response (not just specific locations), use header_filter_by_lua_block. It runs after your content handler has decided what to return but before the headers are sent to the client:

server {
    listen 8080;
    server_name localhost;

    -- Add a custom header to every response
    header_filter_by_lua_block {
        ngx.header["X-Server"] = "OpenResty"
        ngx.header["X-Request-Id"] = ngx.var.request_id
    }

    location / {
        content_by_lua_block {
            ngx.say("Hello, world!")
        }
    }
}

header_filter_by_lua_block is the right place for concerns that cut across all responses — security headers, CORS headers, or adding request IDs for tracing.

Combining Phases: A Practical Example

Let us put everything together. Here is a configuration that uses all three phases for a simple JSON API:

worker_processes 1;
error_log /tmp/openresty-error.log info;

events {
    worker_connections 128;
}

http {
    -- Initialize a shared memory zone for rate limiting
    lua_shared_dict ratelimit 1m;

    server {
        listen 8080;
        server_name localhost;

        -- Add a custom header to every response
        header_filter_by_lua_block {
            ngx.header["X-Server"] = "OpenResty"
            ngx.header["X-Request-Id"] = ngx.var.request_id
        }

        # --- REWRITE PHASE: simple URL normalization ---
        rewrite_by_lua_block {
            local uri = ngx.var.uri
            -- Remove trailing slashes for consistency (but preserve root "/")
            local new_uri = string.match(uri, "^(.+)/$")
            if new_uri then
                ngx.req.set_uri(new_uri, false)
            end
        }

        # --- ACCESS PHASE: rate limiting middleware ---
        access_by_lua_block {
            local key = ngx.var.remote_addr  -- Client IP address
            local dict = ngx.shared.ratelimit
            local limit = 10  -- Max 10 requests
            local window = 60  -- Per 60 seconds

            local current, err = dict:get(key)
            if current and current >= limit then
                ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
            end

            local new_val, err = dict:incr(key, 1)
            if not new_val then
                dict:set(key, 1, window)
            end
        }

        # --- ACCESS PHASE: authentication check ---
        access_by_lua_block {
            -- Skip auth for health check endpoint
            if ngx.var.uri == "/health" then
                return  -- Continue to next phase
            end

            local token = ngx.req.get_headers()["Authorization"]
            if not token then
                ngx.exit(ngx.HTTP_UNAUTHORIZED)
            end
            -- In a real app, you would validate the token here
        }

        # --- CONTENT PHASE: route to handlers ---
        content_by_lua_block {
            local uri = ngx.var.uri
            local method = ngx.req.get_method()

            if uri == "/health" then
                ngx.header["Content-Type"] = "application/json"
                ngx.say('{"status": "ok"}')

            elseif uri == "/users" then
                if method == "GET" then
                    ngx.header["Content-Type"] = "application/json"
                    ngx.say('{"users": ["alice", "bob", "carol"]}')
                elseif method == "POST" then
                    ngx.header["Content-Type"] = "application/json"
                    ngx.say('{"message": "User created"}')
                else
                    ngx.exit(ngx.HTTP_METHOD_NOT_ALLOWED)
                end

            else
                ngx.exit(ngx.HTTP_NOT_FOUND)
            end
        }

        # --- LOG PHASE: record request details ---
        log_by_lua_block {
            -- Log the request method and URI to a file (or send to a logging service)
            local log_file = io.open("/tmp/access.log", "a")
            if log_file then
                log_file:write(
                    ngx.var.request_method .. " " ..
                    ngx.var.uri .. " " ..
                    ngx.var.status .. "\n"
                )
                log_file:close()
            end
        }
    }
}

Here’s what to notice in this config:

In the rewrite phase, we used ngx.req.set_uri(new_uri, false) to modify the URL internally without telling the client. The second argument false means “do an internal rewrite, not an HTTP redirect.” If you passed true, OpenResty would send a 302 redirect to the browser.

In the access phase, we used ngx.shared.ratelimit, which is a shared memory dictionary. This is one of the ways OpenResty lets you share data across all worker processes. In the rate limiting code, dict:incr atomically increments a counter, and dict:set initializes the counter with a TTL (time-to-live) of 60 seconds. This means each client’s request counter resets every minute.

In the content phase, we routed between endpoints based on both the URI and the HTTP method, setting the Content-Type header manually before writing the response with ngx.say.

Finally, in the log phase, we opened a file and wrote a simple access log entry. In production, you would probably send logs to a service like syslog or a hosted logging platform instead.

Common Mistakes and How to Avoid Them

Confusing rewrite and access phases. The rewrite phase is for routing and URL manipulation. The access phase is for security checks. Do not try to do authentication in the rewrite phase — ngx.exit in the rewrite phase does not terminate the request the way it does in the access phase.

Thinking ngx.exit behaves the same everywhere. In the rewrite_by_lua_block, ngx.exit(status) exits the rewrite handler but the request continues to the next phase (unless you pass a status >= 200 that is not ngx.OK). In access_by_lua_block, any non-OK status terminates the request immediately. In content_by_lua_block, ngx.exit terminates the request and the content phase. The log phase still runs normally — use it for any final cleanup or logging you need.

Using ngx.socket.tcp() in the wrong phase. Cosockets (OpenResty’s non-blocking networking API) are only available in the content, access, rewrite, and precontent phases. If you try to use them in log_by_lua_block or header_filter_by_lua_block, your code will fail. The error message you see will be something like “attempt to yield across C-call boundary.”

Forgetting that data is per-worker by default. If you store something in a local variable in your Lua code, it only exists for the current worker process. If you need to share data across all workers (like a session store), use ngx.shared.DICT or an external store like Redis.

Not declaring lua_shared_dict before use. Every ngx.shared.DICT must be declared in the http block with lua_shared_dict <name> <size>; before any worker process can use it. Forgetting this causes a runtime panic.

Summary

Routing and middleware in OpenResty come down to understanding the request pipeline. The rewrite phase lets you inspect and modify the URL before your application sees it. The access phase is a gatekeeper where authentication, authorization, and rate limiting live — and it is the only phase where returning an error truly stops a request in its tracks. The content phase is your application layer, where you generate the actual response, handle different HTTP methods, and match URL patterns. The log phase handles cleanup and recording.

Each phase is a slot where you drop in Lua code. By stacking multiple handlers in the same phase, you build a middleware chain. By using different phases for different concerns, you keep your code organized and each piece focused on a single responsibility.

See Also