I'm currently developing a Symfony webapp, for which I have a testing and production nginx server. My API, which I am currently working on, is supposed to use Bearer tokens for Authorization.
My production server lies under https://api.mydomain.de while my test environment is at https://dev.api.mydomain.de
For now I have set-up a Basic Auth system as following:
location / {
try_files $uri /index.php$is_args$args;
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
}
When entering the dev server, we need the Authorization header as Authorization: Basic <token>
But when testing out my user system I realized that this won't work as well as I thought, when my application also tries authentication using the header.
This means I either request the server using valid login credentials for the app, but nginx doesn't approve my request or the other way around.
I had a few ideas on how to fix this, but was not sure how to realize them or even which one to choose:
auth_request
I considered using the auth_request directive to validate the Bearer token by making a sub-request to an endpoint like /user/validate-token/{token}. I encountered issues with the auth_request block not being allowed inside if statements and had to deal with 502 errors I couldn't fix.
Here's what I had so far:
location / {
try_files $uri /index.php$is_args$args;
# Check if the Authorization header contains a Bearer token
if ($http_authorization ~* ^Bearer\s+(.*)) {
set $bearer_token $1;
# Use an internal location to validate the token
auth_request /internal/token-validation;
}
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
}
location = /internal/token-validation {
internal;
proxy_pass http://localhost/user/validate-token/$bearer_token;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
# Maybe remove auth_basic here ?
location /user/validate-token {
try_files $uri /index.php$is_args$args;
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
}
error_page 401 = @error_401;
location @error_401 {
return 401 "Unauthorized";
}
I also thought about creating separate internal locations for handling Basic Authentication and Bearer token validation. This would allow me to keep the logic clean and separate, but I encountered similiar issues. Also, my application recognized all request urls either as /basic_auth
or /validate_bearer_token
, cause the request was rewritten internally.
Here's what I had:
location /basic_auth {
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
rewrite ^/basic_auth(/.*)$ $1 break;
try_files $uri $uri/ /index.php?$query_string;
}
location ~ ^/validate_bearer_token/(.*) {
internal; # Internal only, not accessible from outside
set $auth_token $1;
# Pass the request to the validation endpoint
proxy_pass http://127.0.0.1/user/validate-token/$auth_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Intercept errors to handle token validation response
proxy_intercept_errors on;
error_page 401 = @error_401;
# I couldn't use 200 for some reason
error_page 399 = @auth_success;
}
# [...]
I also considered renaming the Authorization header in my application to avoid conflicts between Basic Auth and Bearer token authentication. However, I wasn't sure, for one, if this is the best way to deal with this issue, and also I didn't know how to do that, since I am using Symfony with the LexikJWTBundle, which expects the standard Authorization header format. I don't know if this would complicate the integration and could lead to further issues down the line? Is it even possible?
Another approach I thought about was to have a kind of password header, which acts in place of an Authorization Basic header and is checked by my Symfony app, instead of nginx itself. However I can also see how this causes problems, especially when testing out my frontend on https://dev.mydomain.de, but I am open for this solution as well.
I was curious to know, which one of these solutions is the objectively best to use or if there's something I have missed?
As LexikJWTAuthenticationBundle documentation states:
By default only the authorization header mode is enabled:
Authorization: Bearer {token}
See the configuration reference document to enable query string parameter mode or change the header value prefix.
And in the aforementioned configuration reference, we see the following configuration example:
# config/packages/lexik_jwt_authentication.yaml lexik_jwt_authentication: ... # token extraction settings token_extractors: # look for a token as Authorization Header authorization_header: enabled: true prefix: Bearer name: Authorization # check token in a cookie cookie: enabled: false name: BEARER # check token in query string parameter query_parameter: enabled: false name: bearer # check token in a cookie split_cookie: enabled: false cookies: - jwt_hp - jwt_s
This means you can adjust the token extraction settings to use either a different token-check method or an alternative custom HTTP header, e.g. X-Bearer-Auth
. Moreover, you can substitute this custom HTTP header as the Authorization header value when passing the request to the API server:
server {
...
location / {
try_files $uri /index.php$is_args$args;
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
}
location ~ \.php$ {
include fastcgi_params;
# overwrite `Authorization` header for PHP-FPM engine
# with the custom `X-Bearer-Auth` header value
fastcgi_param HTTP_AUTHORIZATION $http_x_bearer_auth;
fastcgi_param SCRIPT_FILENAME $document_root$uri;
fastcgi_pass /path/to/php-fpm.sock;
}
...
(Note: The original Authorization
header value will still be present in the FastCGI request payload; however, only the second, overwritten value will appear in the PHP $_SERVER
array as the $_SERVER['HTTP_AUTHORIZATION']
item.)
You can also conditionally check whether the Authorization
header is of the Basic
or Bearer
type by using both auth_basic
and auth_request
methods. Even if your initial negotiation with the server is secured by basic authentication, adding an Authorization: Bearer ...
header programmatically to an AJAX request using XMLHttpRequest()
:
xhr.setRequestHeader("Authorization", "Bearer " + token);
or fetch()
:
fetch(url, {
method: "POST",
headers: {
"Authorization", "Bearer " + token,
...
},
...
will prevent the browser from including an additional Authorization: Basic ...
header in the request (which is beneficial, as nginx responds with an HTTP 400 Bad Request
error if multiple Authorization
headers are present). The approach you initially chose isn't correct. The if
directive in nginx, when specified in the location
context, differs from typical if
statements in regular programming languages (if you're interested, you can read this answer to find out why it is so.) However, instead you can use two map
blocks along with the following configuration:
map $http_authorization $auth_realm {
~^Bearer off;
default "Restricted Access";
}
map $http_authorization $auth_basic {
~^Basic 1;
# default is empty string
}
server {
...
location / {
# auth_basic is off in case we have an "Authorization: Bearer ..." header
auth_basic $auth_realm;
auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
auth_request /internal/validate/;
try_files $uri /index.php$is_args$args;
}
location /internal/validate/ {
internal;
# skip further processing in case we have an "Authorization: Basic ..." header
if ($auth_basic) { return 200; }
include fastcgi_params;
# the following script validate the "Authorization: Bearer ..." header
# (accessible in PHP script via $_SERVER['HTTP_AUTHORIZATION'])
# script shoud return an HTTP 2xx status code on success or 401/403 otherwise
fastcgi_param SCRIPT_FILENAME $document_root/relative/path/to/validator.php;
fastcgi_pass /path/to/php-fpm.sock;
}
...
You can even define a separate Symfony route for token verification, e.g. /verify_token
. To use this route for validation request, redefine the REQUEST_URI
FastCGI parameter before passing the request to the Symfony controller, modifying the internal location from the previous example as follows:
location /internal/validate/ {
internal;
# skip further processing in case we have an "Authorization: Basic ..." header
if ($auth_basic) { return 200; }
include fastcgi_params;
# overwrite the route seen by Symfony
fastcgi_param REQUEST_URI "/validate_token";
# pass the request to Symfony's main controller
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_pass /path/to/php-fpm.sock;
}