js_access shipped today. We compiled Gleam to it.

Builds
By darkanchor teamMay 20, 2026

Today upstream njs PR #1044 get shipped in the njs 0.9.9 release. js_access is now live — a new directive that puts JavaScript in nginx’s ACCESS phase, before any content handler fires, before proxy_pass opens a connection to the upstream. For authorization, that changes everything. The right time to deny a request is before nginx commits resources to serving it.

We had an existing authorization module written in Gleam, running in the content phase via js_content. It worked. But content-phase authorization has a structural problem: by the time your handler runs, nginx has already buffered the request body, resolved the upstream, and opened a connection. If you’re going to say no, you want to say it earlier.

So we compiled Gleam to the access phase on day one. Here’s what the design looks like, and why the type system matters when you’re targeting a bleeding-edge njs feature.

The design: rules as first-class values

The core of the module is three types. Decision is either Allow or Deny(status, reason) — a single type flows through every rule, no exceptions, no side channels. Context holds the structured request state: method, path, headers, claims, query, and body. And Rule is simply fn(Context) -> Decision.

That last one is the design’s load-bearing wall. A rule is a function — not a config string, not a regex, not a DSL parsed at runtime. A function that takes structured context and returns a decision. That means rules compose with ordinary function combinators: all_of short-circuits on the first Deny, any_of on the first Allow, not_ inverts etc.

The Gleam compiler enforces exhaustiveness on every case decision { Allow -> ... Deny -> ... } statement. If a handler forgets a branch, the build fails. That guarantee turns out to matter most precisely where you’d least want to discover a gap — at the access phase, where a missing branch means a silent fallthrough to the content handler.

With the Gleam DSL
Rules are values. The compiler checks your work.
Every rule is fn(Context) → Decision. Compose with all_of / any_of / not_. The Gleam compiler enforces exhaustiveness — every Decision branch must be handled, or the build fails. Unit-test rules as pure functions. No nginx required.
Without it
Ad-hoc functions. Runtime surprises in production.
Each handler is a custom JavaScript function. Composition means manual nesting. A missing else or unhandled rejection falls through to allow. The njs VM won't tell you about the gap — your users will.

The type safety isn’t theoretical. While building the access-phase adapters, the Gleam compiler caught two exhaustiveness errors where a policy branch would have silently fallen through to js_content. In a JavaScript-native implementation, those failures would be runtime surprises. In Gleam, they’re build failures.

The access-phase adapter: same DSL, earlier phase

The jump from content phase to access phase required exactly one new function: an adapter that translates a Decision into access-phase signaling.

content phase
→ access phase
One DSL, two signal contracts.
In js_content, allow = r.return(204). In js_access, allow = return nothing. nginx sees a normal return and advances to the content phase. The policy DSL doesn't change — only the adapter that sits between Decision and the nginx wire.

Allow is a no-op — return nothing, let nginx proceed to js_content or proxy_pass. Deny calls r.return(status) immediately, before any upstream connection opens. The policy DSL is unchanged. It’s the same Rule = fn(Context) -> Decision. The only thing that differs is where you wire the decision — and in the access phase, you don’t wire allow at all.

Here’s the access-phase handler for a synchronous method gate — structurally identical to its content-phase counterpart, with exactly one line changed:

fn access_check(r: HTTPRequest) -> Nil {
  let ctx = context_from_request(r)
  let rules = [policy.method_in(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"])]
  case policy.evaluate(ctx, rules) {
    Allow -> Nil                        // access phase: return nothing
    Deny(status, reason) -> {
      let _ = http.log(r, "authz: access denied — " <> reason)
      http.return_code(r, status)
    }
  }
}

Body-aware policies, fail-closed

One of the things js_access unlocks that content-phase handlers couldn’t practically do is reading the request body before the upstream. The new njs API exposes readRequestJSON() and readRequestForm() — both available in the access phase, both asynchronous.

We built two async access-phase handlers that read the body, extract named fields, and apply policy:

fn access_json_check(r: HTTPRequest) -> Promise(Nil) {
  case configured_body_policy(r, "json") {
    None -> promise.resolve(Nil)        // config error already returned 500
    Some(#(field_names, required)) ->
      http.read_request_json(r)
      |> promise.await(fn(json_obj) {
        let body_dict = body.from_json(json_obj, field_names)
        let ctx = Context(..context_from_request(r), body: body_dict)
        policy.evaluate(ctx, [policy.body_param_present(required)])
        |> apply_access_decision(r, _, "authz: access json denied — ")
      })
      |> promise.rescue(fn(_) {
        http.return_code(r, 400)        // malformed body → fail closed
        Nil
      })
  }
}

Three edges are covered by design here. First, if $authz_body_fields and $authz_body_required aren’t coherent in the nginx config, the handler returns 500 — no silent fallthrough. Second, if readRequestJSON() rejects (malformed input, wrong Content-Type), the promise.rescue path returns 400 — fail closed, not fail open. Third, the Gleam compiler confirms at build time that both the None (config error) and Some (normal) branches are handled.

The form-body adapter is structurally identical. The nginx config tells the handler which fields to look for via two variables — $authz_body_fields (comma-separated field names) and $authz_body_required (the one that must be present). The Gleam code validates the coherence of those variables before touching the request body.

What this unlocks

The access phase changes the economics of authorization in nginx. A content-phase deny still costs a connection to the upstream. An access-phase deny costs nothing — nginx returns the status code before it resolves a backend.

More importantly, the policy DSL now spans both phases. A rule written once — claim_contains_one_of("role", ["admin", "support"]) — works in a content-phase handler behind auth_request, in an access-phase handler before proxy_pass, or in both. The type system doesn’t care where the function runs. It only cares that every Decision branch resolves to a concrete status code or a pass-through.

86 unit tests cover the rules, combinators, and async evaluation paths. All of them run without nginx. Rules are pure functions; evaluate is a fold; the only external dependencies are the njs HTTP APIs, and those are mocked at the adapter boundary.

Adapting to a new JS runtime

Gleam compiles to JavaScript. The njs runtime is a JavaScript engine (QuickJS) embedded in nginx. Matching Gleam’s output to QuickJS required exactly one accommodation: Gleam’s standard library uses globalThis in a few places, and recent njs builds expose that. Everything else — closures, promises, pattern matching, the |> pipe operator — Just Works.

The compiled output is a standard njs bundle. nginx loads it via js_import and has no visibility into the Gleam source. The type safety and composability are entirely build-time properties. At runtime, it’s just JavaScript.

What to take away

Access phase > content phase for authorization. A deny before proxy_pass connects costs nothing. A deny in the content phase already paid for the upstream connection and body buffering. js_access makes the right phase programmable.
🧩
Rules as functions compose better than config strings. all_of, any_of, not_ work because rules are values. The Gleam compiler guarantees every branch is handled. Unit-test policy trees without standing up nginx.
🛡️
Fail closed at every boundary. Malformed JSON → 400. Missing config → 500. Promise rejection → 400. An access gate that falls through to allow on a parse error isn't a gate — and the type system makes it impossible to forget the error path.

That last point is worth sitting with. The Gleam compiler rejected two handler drafts where a policy branch would have silently fallen through to the content phase. Those were not theoretical gaps — they were real edge cases in the body-reading adapters that would have become production incidents. In a dynamically-typed njs handler, they would have gone live. In Gleam, they were build failures.