Turning off JWT auth for my VPN in nginx

I run about thirty self-hosted services behind one nginx reverse proxy, and all of them sit behind a JWT auth gateway that logs you in with Google. For anything reachable from the internet that’s what I want. But most of the time I’m already on my own WireGuard VPN, and having to log in again just to open a dashboard got annoying.

So I wanted auth to stay on for everyone, except traffic coming from the VPN subnet. nginx has a geo module for exactly this kind of thing. It sets a variable based on the client’s address:

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

The plan was to pass that to the JWT module with auth_jwt_enabled $jwt_enabled;. When I tried it, nginx refused to start, saying the value was invalid.

Why the variable didn’t work

In an nginx config it looks like variables work everywhere, but they only work in directives that were written to accept them. Each directive decides in its C code how its value gets read, and auth_jwt_enabled was set up as an on/off flag:

NGX_HTTP_MAIN_CONF | NGX_HTTP_SRV_CONF | NGX_HTTP_LOC_CONF | NGX_CONF_FLAG,
ngx_conf_set_flag_slot,

A flag like this gets read once, when nginx loads its config. It has to be exactly on or off, and it gets stored as a 1 or a 0. $jwt_enabled isn’t either of those, so nginx rejected it before any request came in.

Adding variable support

nginx already has a way for a directive to take a value that gets worked out per request, called a complex value. So I changed auth_jwt_enabled to use that instead of a flag. The config now stores the expression, and the module evaluates it for each request:

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;
    }
  }
}

Auth only turns off if the value is exactly off. If it’s empty, misspelled, or the geo block doesn’t match anything, auth stays on. Plain auth_jwt_enabled on; still works the same as before, since on is just a value that never changes.

Every site started asking for a token

After building my first version, every site on the box started asking for a JWT, including ones that had no JWT config at all.

With the old flag, “not set” had its own special value, and the module turned that into off. That’s why it did nothing on sites that never mentioned it. With my change the setting was a pointer, and “not set” was now just an empty pointer. My code started from enabled = 1 and only turned it off for an explicit off, so an empty pointer meant on.

I fixed it by adding a check at the start: if no JWT key is configured, the module steps aside. Without a key it can’t check tokens anyway.

Getting it merged

The maintainer liked the change and added some formatting fixes and tests. He also removed my key check, saying other checks further down already handled that case. I rebuilt with his changes and tried a config with no JWT settings, and nothing asked for a token, so I agreed to drop it.

For the tests, he couldn’t easily test a subnet in CI, so he set the variable from a request header instead:

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

A request with Test-Auth-Enabled: on should get a 401, and one with off should get a 200.

It was released in v2.4.0 about a month after I opened the PR, and my reverse proxy runs it now. On the VPN, dashboards open straight away, and from anywhere else I get the Google login.

Back to writing