Authentication vs Authorization in Microservices Architecture
Problem
When I started building microservices, I kept running into this confusion: should authentication and authorization both happen at the API gateway? Or should they be distributed?
I saw this question on Reddit r/node:
“Should I authenticate at the API gateway level and also handle authorization there, or should each service handle its own authorization?”
The responses were consistent, but the pattern wasn’t immediately clear to me. So I dug deeper.
What’s the Confusion?
The problem stems from conflating two distinct security concerns:
Authentication - Verifying who a user is (identity verification)
Authorization - Verifying what a user can do (permission checking)
In monolithic applications, both often happen in the same place. But in microservices, they should be separated.
The Pattern: Gateway for Auth, Services for Authz
The answer I found from experienced developers on Reddit was clear:
- Authentication at the API Gateway - Verify identity once, attach user context to requests
- Authorization at Each Service - Each service handles its own fine-grained permissions
Here’s why this makes sense from a Reddit thread on r/node:
“also worth noting: authentication (who are you) at the gateway, authorization (can you do this) at each service. different services usually have different permission models so that part shouldn’t be centralized” — Hung_Hoang_the (7 upvotes)
“The gateway authenticates the jwt, the services manage fine grained authorization” — fromage-du-omelette (3 upvotes)
Why This Separation Matters
Each microservice owns its domain. An Order service has different permission requirements than a Payment service or a User service.
Consider this scenario:
- Order Service: Users can view their own orders, admins can view all orders
- Payment Service: Only authorized payment processors can initiate transactions
- Inventory Service: Warehouse staff can update stock, regular users cannot
If you centralize authorization at the gateway, you couple all services to a single permission model. That defeats the purpose of microservice independence.
Implementation: Gateway Authentication
Here’s a simplified example of how I set up authentication at the API Gateway (using Node.js with Express):
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1];
if (!token) { return res.status(401).json({ error: 'No token provided' }); }
jwt.verify(token, process.env.JWT_SECRET, (err, user) => { if (err) { return res.status(403).json({ error: 'Invalid token' }); }
// Attach user context to request // Services will use this for authorization req.user = { id: user.sub, email: user.email, roles: user.roles };
next(); });}
module.exports = { authenticateToken };The gateway:
- Verifies the JWT signature
- Extracts user identity
- Attaches user context to the request
- Passes the request to the target service
The gateway does NOT check if the user can perform the specific action. It only verifies identity.
Implementation: Service-Level Authorization
Each service handles its own authorization. Here’s an example for an Order Service:
// Order service has its own permission modelconst ORDER_PERMISSIONS = { VIEW_OWN: 'order:read:own', VIEW_ALL: 'order:read:all', CREATE: 'order:create', CANCEL: 'order:cancel', PROCESS: 'order:process'};
function canViewOrder(user, order) { // Admin role can view all orders if (user.roles.includes('admin')) { return true; }
// Regular users can only view their own orders return order.userId === user.id;}
function canCancelOrder(user, order) { // Only order owner can cancel, and only within 30 minutes const orderAge = Date.now() - new Date(order.createdAt).getTime(); const thirtyMinutes = 30 * 60 * 1000;
return order.userId === user.id && orderAge < thirtyMinutes;}
function canProcessOrder(user) { // Only warehouse staff can process orders return user.roles.includes('warehouse_staff');}
function checkPermission(user, action, resource) { const checkers = { 'view': canViewOrder, 'cancel': canCancelOrder, 'process': canProcessOrder };
const checker = checkers[action]; if (!checker) { return false; }
return checker(user, resource);}
module.exports = { canViewOrder, canCancelOrder, canProcessOrder, checkPermission, ORDER_PERMISSIONS};The Order Service defines its own business rules:
- Admins can view all orders
- Users can only view their own orders
- Orders can only be cancelled within 30 minutes
- Only warehouse staff can process orders
These rules are domain-specific and belong to the Order Service.
Using the Authorization in Routes
const express = require('express');const router = express.Router();const { canViewOrder, canCancelOrder, canProcessOrder } = require('./authz');
// Middleware to extract user from gateway headersfunction extractUser(req, res, next) { // Gateway passes user context in headers req.user = { id: req.headers['x-user-id'], email: req.headers['x-user-email'], roles: JSON.parse(req.headers['x-user-roles'] || '[]') }; next();}
router.use(extractUser);
router.get('/orders/:id', async (req, res) => { const order = await getOrder(req.params.id);
if (!canViewOrder(req.user, order)) { return res.status(403).json({ error: 'Access denied' }); }
res.json(order);});
router.post('/orders/:id/cancel', async (req, res) => { const order = await getOrder(req.params.id);
if (!canCancelOrder(req.user, order)) { return res.status(403).json({ error: 'Cannot cancel this order' }); }
const cancelled = await cancelOrder(req.params.id); res.json(cancelled);});
router.post('/orders/:id/process', async (req, res) => { if (!canProcessOrder(req.user)) { return res.status(403).json({ error: 'Processing not allowed' }); }
const processed = await processOrder(req.params.id); res.json(processed);});
module.exports = router;Alternative: Policy-Based Authorization (OPA)
For more complex scenarios, I’ve used Open Policy Agent (OPA) for policy-based authorization. This keeps authorization logic declarative:
package order
default allow = false
# Admin can do everythingallow { input.user.roles[_] == "admin"}
# Users can view their own ordersallow { input.action == "view" input.user.id == input.resource.userId}
# Users can cancel orders within 30 minutesallow { input.action == "cancel" input.user.id == input.resource.userId order_within_time_limit(input.resource)}
order_within_time_limit(order) { now := time.now_ns() created := time.parse_rfc3339_ns(order.createdAt) (now - created) < 18000000000000 # 30 minutes in nanoseconds}
# Warehouse staff can process ordersallow { input.action == "process" input.user.roles[_] == "warehouse_staff"}The service queries OPA:
const axios = require('axios');
async function checkAuthz(user, action, resource) { const response = await axios.post('http://opa:8181/v1/data/order/allow', { input: { user: user, action: action, resource: resource } });
return response.data.result;}
module.exports = { checkAuthz };How Gateway and Services Communicate
The typical flow looks like this:
┌─────────┐ ┌──────────────┐ ┌──────────────┐│ Client │───→│ API Gateway │───→│ Order Service│└─────────┘ └──────────────┘ └──────────────┘ │ │ │ │ ▼ ▼ ┌─────────┐ ┌─────────────┐ │ Verify │ │ Check perms │ │ JWT │ │ for order │ └─────────┘ └─────────────┘ │ │ ▼ ▼ ┌─────────┐ ┌─────────────┐ │ Extract │ │ Allow/Deny │ │ User │ │ request │ └─────────┘ └─────────────┘ │ ▼ ┌──────────────────┐ │ Add headers: │ │ X-User-Id │ │ X-User-Email │ │ X-User-Roles │ └──────────────────┘The gateway passes user identity via headers (or a signed internal token). Each service trusts the gateway’s authentication and applies its own authorization rules.
Common Authorization Patterns
| Pattern | Description | Best For |
|---|---|---|
| RBAC | Role-Based Access Control | Simple role hierarchies |
| ABAC | Attribute-Based Access Control | Complex, dynamic rules |
| PBAC | Policy-Based (OPA, Casbin) | Centralized policy management |
RBAC works well for most cases. ABAC adds flexibility when permissions depend on resource attributes. PBAC separates policy from code entirely.
The Reason
The key insight is that microservices should be independent. Authorization rules are domain logic. When the Payment Service decides who can initiate a refund, that’s business logic, not infrastructure.
By centralizing authentication at the gateway:
- We verify identity once
- We reduce code duplication
- We simplify token management
By distributing authorization to each service:
- Each service owns its permissions
- Services remain loosely coupled
- Authorization evolves with business needs
Summary
In this post, I explained why authentication belongs at the API gateway while authorization belongs at each microservice. The key point is that authentication verifies identity (which can be centralized), while authorization checks permissions (which are domain-specific). Each service should define and enforce its own authorization rules based on its business logic. This separation keeps microservices independent and makes the system more maintainable.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 NIST Access Control Guide
- 👨💻 Open Policy Agent Documentation
- 👨💻 OAuth 2.0 Best Practices
- 👨💻 Reddit Discussion: API Gateway Auth Pattern
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments