In a microservices architecture, separating concerns is critical for maintainability, scalability, and security. One key decision when building APIs is how and where to handle authentication. A common pattern is to delegate authentication to a dedicated authentication microservice, which issues tokens (e.g., JWTs), and use those tokens to access protected resources on independent backend APIs. When working on an infrastructure change, we faced the challenge of either integrating authentication in the Node.js backend (without the proper libraries) or maintaining a single backend solely for authorization.
The options we considered were:
- Having the Go backend validate the token and proxy to the Node.js backend over authenticated routes. (We tried this, but the Go proxy became messy and difficult to maintain.)
- Performing authentication in Node.js (infrastructure restrictions led us to abandon this approach.)
- Implementing a different authentication method using the existing infrastructure
And this third one is what we came up with after investigating.
This post demonstrates how to validate JWT tokens directly in Nginx before routing requests to your protected Node.js API, centralizing authorization enforcement at the gateway layer.
This keeps the authentication within the infrastructure boundaries and allows us to simplify both the Go backend and the Node.js backend by relying on the NGINX layer.
Why JWT at the Proxy?
- Decouples concerns: Authentication logic doesn't pollute your API code.
- Consistent enforcement: All routes must pass the same token checks before hitting backend services.
- Performance: Nginx (especially via OpenResty) is efficient and fast at handling token validation.
Options for JWT Validation
-
Validate JWT in each backend service
- Pros: Full control per service.
- Cons: Repeated logic, potential for inconsistency.
-
Use Nginx with a third-party JWT module
- Commercial option with NGINX Plus.
-
Use OpenResty (Nginx + Lua) with
lua-resty-jwt
- Open-source, flexible, and efficient.
OpenResty + Lua
We use OpenResty and the lua-resty-jwt
library to inspect JWTs in the Nginx layer. If valid, we forward requests to the backend. Otherwise, Nginx returns a 401 response.
Architecture
-
auth-api
: issues JWTs via login endpoint. -
node-api
: protected and public routes. -
nginx
: gateway with Lua-based JWT validation.
Security Considerations
Some of these concerns were left out of this POC but we would like to mention for a proper production implementation. Please read through and evaluate wether it fits to your scenario or not.
Protection Against Common Attacks
-
Replay Attacks
- Implement token expiration (exp claim)
- Use short-lived tokens (15-60 minutes)
- Consider implementing a token blacklist for revoked tokens
- Use nonce values in token claims
- Implement request timestamp validation
-
Token Theft Prevention
- Always use HTTPS for token transmission
- Implement secure cookie attributes (HttpOnly, Secure, SameSite)
- Use token binding to prevent token reuse
- Implement rate limiting on authentication endpoints
- Monitor for suspicious patterns (multiple failed validations)
Token Expiration Best Practices
-
Short-lived Access Tokens
- Set expiration time between 15-60 minutes
- Use refresh tokens for longer sessions
- Implement sliding expiration for active users
-
Refresh Token Strategy
- Longer expiration (days/weeks)
- Store refresh tokens securely
- Implement refresh token rotation
- Maintain a refresh token family tree
Expiration Implementation
-- Example of expiration check in Lua
local jwt = require "resty.jwt"
local validators = require "resty.jwt-validators"
validators.set_system_leeway(0) -- Strict time validation
validators.register_validator("exp", validators.opt_is_not_expired())
-
Grace Period Considerations
- Implement a small grace period (30 seconds) for clock skew
- Handle token expiration gracefully
- Provide clear error messages for expired tokens
- Implement automatic token refresh when possible
Project Layout
You can find the full source here:
GitHub Repo: martinfernandezcx/NGINXAUTH
How It Works
- Client logs in via
/api/auth/login
, receives JWT. - Client sends
Authorization: Bearer <token>
on protected requests. - Nginx runs a Lua script to:
- Check token structure.
- Validate signature and expiration.
- Inject user ID into a request header.
- Validated requests reach the Node.js service with identity attached.
Testing with Postman
The project includes a comprehensive Postman test suite to verify the JWT authentication flow and API endpoints. The test suite covers authentication, public routes, and protected routes with various scenarios.
Test Suite Structure
The Postman collection (postman/jwt-nginx-auth-tests.json
) includes:
-
Authentication Tests
- Login endpoint validation
- Token format verification
- Automatic token storage for subsequent requests
-
Public Endpoint Tests
- Access to public routes
- Response format validation
-
Protected Endpoint Tests
- Access without token (401)
- Access with invalid token (401)
- Access with valid token (200)
- Response payload validation
Running the Tests
-
Prerequisites
- Install Postman
- Start the application:
docker-compose up --build
-
Import the Collection
- Open Postman
- Click "Import" button
- Select the
postman/jwt-nginx-auth-tests.json
file - select the
postman\environment.json
file - The collection will be imported with all test cases
-
Run the Tests
- Select the "JWT Nginx Auth Tests" collection
- Click the "Run" button
- Postman will execute all tests in sequence
- View test results in the Postman console
-
Test Flow
- Tests run in a specific order to ensure proper token handling
- Login test stores the token for subsequent requests
- Protected route tests verify token validation
- Each test includes assertions for status codes and response formats
Test Cases break down
- Login Test
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("Response has token", function () {
var jsonData = pm.response.json();
pm.expect(jsonData).to.have.property('token');
});
- Protected Route Test
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("Response contains protected data", function () {
var jsonData = pm.response.json();
pm.expect(jsonData).to.have.property('message');
});
Environment Variables
The test suite uses Postman environment variables:
-
auth_token
: Automatically set after successful login - Used in subsequent requests to protected routes
Continuous Integration
The Postman collection can be integrated into CI/CD pipelines using:
- Newman CLI tool
- Postman's CI/CD integrations
- Custom test runners
Example Newman command:
newman run postman/jwt-nginx-auth-tests.json -e postman/environment.json
Running the tests
To run the tests you can use npm run test:postman:cli, or import both files on postman and run it there as mentioned above.
Conclusion
Centralizing JWT validation in the proxy simplifies backend services, enforces uniform security, and keeps authentication logic out of each microservice. This pattern is ideal for architectures using distinct auth and business logic APIs.
In contrast, validating tokens in the Node.js API itself might allow greater control over roles or context-based access logic but at the cost of duplication and potential inconsistency.
OpenResty strikes a solid balance between performance, flexibility, and maintainability in JWT-based authentication.
Apendix-A: Problems Found and Solutions
During the implementation of this JWT authentication system, we encountered several issues that required specific solutions:
-
OpenResty Dependencies
- Problem: Missing Perl and curl in the OpenResty Alpine image
- Solution: Added required packages in Dockerfile:
RUN apk add --no-cache perl curl
-
Nginx User Configuration
- Problem: Missing nginx user in the container
- Solution: Created nginx user and group:
RUN addgroup -S nginx && adduser -S -G nginx nginx
-
MIME Types Configuration
- Problem: Missing mime.types file in OpenResty Alpine image
- Solution: Created custom mime.types file and copied it to the correct location:
COPY mime.types /etc/nginx/mime.types
-
Lua Package Path
- Problem: Lua package path directive in wrong context
- Solution: Moved lua_package_path to http context in nginx.conf:
http { lua_package_path "/usr/local/openresty/lualib/?.lua;;"; lua_package_cpath "/usr/local/openresty/lualib/?.so;;"; }
-
Log Directory Permissions
- Problem: Nginx couldn't write to log directory
- Solution: Created log directory and set proper permissions:
RUN mkdir -p /var/log/nginx && \ chown -R nginx:nginx /var/log/nginx
-
Unit tests and routes issues
- Problem: Postman tests were failing with 404 on /protected
- Solution: Changed auth-api/index.js /login route and node-api/index.js /protected to /user
These solutions ensure proper functionality of the JWT authentication system while maintaining security and following best practices for containerized applications.
Top comments (0)