Teaching an NGINX directive to take a variable, so my VPN could skip the login

I run about thirty self-hosted services behind a single NGINX reverse proxy, all of them sitting behind a JWT auth gateway backed by Google OAuth. That is the right default for anything reachable from the internet.

It is a slightly irritating default when I am already on my own WireGuard tunnel, on my own network, and I just want to look at a dashboard. I wanted one rule: authenticate everything, except traffic arriving from the VPN subnet.

nginx is very good at this sort of thing. The geo module exists precisely to turn a client address into a variable:

geo $jwt_enabled {
    default        on;
    10.10.10.0/20  off;   # VPN subnet
}

Then hand $jwt_enabled to the directive that turns authentication on and off, and you are done in four lines.

Except you are not, because the directive would not take it.

Why a directive cannot always take a variable

This is the part I had not internalised before, and it is worth spelling out, because from the config file everything looks like a variable.

An nginx module declares each directive with a parse-time handler and a set of flags. The one here looked like this:

{ngx_string("auth_jwt_enabled"),
 NGX_HTTP_MAIN_CONF | NGX_HTTP_SRV_CONF | NGX_HTTP_LOC_CONF | NGX_CONF_FLAG,
 ngx_conf_set_flag_slot,
 NGX_HTTP_LOC_CONF_OFFSET,
 offsetof(auth_jwt_conf_t, enabled),
 NULL},

NGX_CONF_FLAG with ngx_conf_set_flag_slot means: at config load time, read the token, expect exactly the string on or off, and write a 1 or 0 into an ngx_flag_t field. By the time a request arrives there is no string left, no expression, nothing to evaluate. Just an integer that was decided when nginx started.

So auth_jwt_enabled $jwt_enabled; does not fail at runtime. It fails at startup, with a complaint about an invalid value, because $jwt_enabled is neither on nor off.

A variable is only meaningful to a directive that was written to defer.

The change

nginx already provides the machinery: complex values. A directive declared with ngx_http_set_complex_value_slot stores a compiled expression, and the module evaluates it per request with ngx_http_complex_value().

Three coordinated edits. The directive:

{ngx_string("auth_jwt_enabled"),
 NGX_HTTP_MAIN_CONF | NGX_HTTP_SRV_CONF | NGX_HTTP_LOC_CONF | NGX_CONF_TAKE1,
 ngx_http_set_complex_value_slot,
 NGX_HTTP_LOC_CONF_OFFSET,
 offsetof(auth_jwt_conf_t, enabled),
 NULL},

The field, from a flag to a pointer:

-  ngx_flag_t enabled;
+  ngx_http_complex_value_t *enabled;

And the decision, moved from startup to request time:

ngx_int_t enabled = 1;

if (jwtcf->enabled != NULL) {
  ngx_str_t cv;
  if (ngx_http_complex_value(r, jwtcf->enabled, &cv) == NGX_OK && cv.len > 0) {
    if (ngx_strncmp(cv.data, "off", 3) == 0) {
      enabled = 0;
    }
  }
}

NGX_CONF_FLAG became NGX_CONF_TAKE1, because the directive now takes one arbitrary argument rather than a boolean the parser recognises. Static configs keep working unchanged: auth_jwt_enabled on; is now a complex value that happens to be constant, which evaluates to the string on on every request.

Note which way the comparison runs. Only the literal off disables authentication. An empty value, a typo, a geo block that fell through to nothing: all of those leave enabled at 1 and the request gets authenticated. For a security directive that is the correct direction to fail, and it was worth being deliberate about rather than writing != "on".

The default flipped, and that broke everything

Here is the part I did not see coming.

ngx_flag_t fields use nginx’s NGX_CONF_UNSET sentinel, and the module’s merge function resolved unset to 0:

conf->enabled = prev->enabled == NGX_CONF_UNSET ? 0 : prev->enabled;

Unconfigured meant off. That is what made the module inert in a config that never mentioned it.

A pointer cannot use that sentinel. Unset is now NULL, inheritance is a plain pointer copy, and my runtime code starts from ngx_int_t enabled = 1 and only ever clears it on an explicit off.

Read those two together: NULL no longer means off, it means “nothing said otherwise”, and the code says the default is on. So after compiling my first version, JWT authentication was applied globally, on an ordinary nginx config with no JWT directives anywhere in it.

That is about as loud as a regression gets. Every site on the box demanding a token nobody had configured.

My fix was a guard at the top of the handler: if no key and no keyfile are configured, this module has nothing to validate against, so decline.

// Only activate JWT logic if key or keyfile_path is set
if (jwtcf->key.len == 0 && jwtcf->keyfile_path.len == 0) {
  return NGX_DECLINED;
}

Using “is a key configured” as the proxy for “is this module in use” is not elegant, but it is true: a JWT module with no key cannot do its job under any configuration.

The review

I opened the PR in mid-August and, after a couple of weeks, sent a polite nudge asking for even a quick reaction so I could refine the approach. The maintainer replied the same day, called it a good idea, and came back with formatting cleanup and tests.

He also removed my guard, saying he thought it was redundant because similar checks are handled further down, and asked me to add it back in the right place if it really mattered.

That is exactly the sort of comment worth taking seriously rather than defending, because it is checkable. I rebuilt with his commits and ran a plain config with no JWT directives against it. The global-auth problem did not reappear. His restructuring had moved the early-exit somewhere that covered the case my guard had been papering over, so the guard genuinely was redundant against the new shape of the code. I said so and agreed to drop it.

The tests he added are the neat part, because testing an IP-based feature in CI is awkward. Instead of a subnet, drive the variable from a request header:

map $http_test_auth_enabled $jwt_enabled {
    default  on;
    on       on;
    off      off;
}

location /enabled/variable {
    auth_jwt_enabled $jwt_enabled;
    ...
}

Then two curl cases: send Test-Auth-Enabled: on and expect 401, send off and expect 200. That tests the mechanism that actually changed, which is “can this directive read a runtime variable”, without needing a second network.

Merged and released as v2.4.0, about a month after I opened it.

What I took from it

The config file lies to you about what is a variable. $foo looks uniform everywhere it appears, but whether a directive can see it is decided in C, at module-definition time, by which parse handler the author picked. There is no way to tell from the outside except by trying it and reading the error.

The more useful lesson is about sentinels. Changing a field’s type quietly changed what “unconfigured” meant, and the default went from off to on in a security module. Nothing in the diff said “default changed”; it fell out of ngx_flag_t having a sentinel and a pointer not having one. When you change a representation, the values that were previously impossible are the ones to go looking at.

And the feature does what I wanted. On the VPN, dashboards open. Off it, Google OAuth. Four lines of geo and one directive that finally accepts what the rest of nginx has been handing it all along.

Back to writing