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.