How to Pass User Context from API Gateway to Microservices
I deployed an API gateway in front of my microservices, authenticated the JWT token successfully, but then realized my downstream services had no idea who was making the request. I could either forward the raw token and decode it again in every service (duplication nightmare), or figure out a clean way to pass the already-verified user context downstream.
The Problem
After the API gateway verifies authentication, downstream services need to know:
- Who made the request (userId)
- What permissions they have (roles, scopes)
- An audit trail for compliance
Without this context, services can’t authorize actions or log who did what.
Anti-patterns I Almost Fell Into
Anti-pattern #1: Forwarding the raw JWT token
Every service would need to decode and verify the token. That means duplicating authentication logic, managing JWT libraries in multiple places, and slower performance from repeated verification.
Anti-pattern #2: No context at all
The gateway authenticates, but services receive no user information. They can’t make authorization decisions or audit actions properly.
The Solution: Gateway Decodes, Services Trust
The pattern that works: the API gateway verifies the token once, extracts the claims, and injects user context into requests forwarded to downstream services.
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) { return res.status(401).json({ error: 'No token provided' }); }
try { // Gateway verifies the token const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Extract user context from claims const userContext = { userId: decoded.sub, email: decoded.email, roles: decoded.roles || [], permissions: decoded.permissions || [] };
// Inject context into headers for downstream services req.headers['x-user-id'] = userContext.userId; req.headers['x-user-email'] = userContext.email; req.headers['x-user-roles'] = JSON.stringify(userContext.roles); req.headers['x-user-permissions'] = JSON.stringify(userContext.permissions);
// Attach to request for gateway's own use req.user = userContext;
next(); } catch (error) { return res.status(401).json({ error: 'Invalid token' }); }}
module.exports = { authMiddleware };The gateway does the heavy lifting once. Services downstream simply read the headers.
Implementation Options
Option 1: HTTP Headers (REST APIs)
For HTTP-based microservices, custom headers are straightforward.
function extractUserContext(req, res, next) { // Trust the gateway - it already verified const userId = req.headers['x-user-id']; const roles = JSON.parse(req.headers['x-user-roles'] || '[]'); const permissions = JSON.parse(req.headers['x-user-permissions'] || '[]');
if (!userId) { return res.status(401).json({ error: 'No user context' }); }
req.user = { userId, roles, permissions }; next();}
function requirePermission(permission) { return (req, res, next) => { if (!req.user.permissions.includes(permission)) { return res.status(403).json({ error: 'Insufficient permissions' }); } next(); };}
module.exports = { extractUserContext, requirePermission };const express = require('express');const { extractUserContext, requirePermission } = require('./middleware');
const router = express.Router();
// Apply context extraction to all routesrouter.use(extractUserContext);
// Use permissions for authorizationrouter.post('/orders', requirePermission('orders:create'), async (req, res) => { // req.user is available here const order = await createOrder({ ...req.body, createdBy: req.user.userId });
// Audit log with user context await auditLog({ action: 'order_created', userId: req.user.userId, orderId: order.id, timestamp: new Date() });
res.json(order); });
module.exports = router;Option 2: gRPC Metadata
For gRPC services, use metadata to pass context.
const grpc = require('@grpc/grpc-js');
function createMetadataWithUserContext(userContext) { const metadata = new grpc.Metadata();
// gRPC metadata values must be strings metadata.set('x-user-id', userContext.userId); metadata.set('x-user-email', userContext.email); metadata.set('x-user-roles', JSON.stringify(userContext.roles)); metadata.set('x-user-permissions', JSON.stringify(userContext.permissions));
return metadata;}
async function callOrderService(userContext, orderData) { const metadata = createMetadataWithUserContext(userContext);
return new Promise((resolve, reject) => { orderServiceClient.CreateOrder( orderData, metadata, (error, response) => { if (error) reject(error); else resolve(response); } ); });}
module.exports = { createMetadataWithUserContext, callOrderService };function extractGrpcContext(call) { const userId = call.metadata.get('x-user-id')[0]; const roles = JSON.parse(call.metadata.get('x-user-roles')[0] || '[]'); const permissions = JSON.parse(call.metadata.get('x-user-permissions')[0] || '[]');
return { userId, roles, permissions };}
async function createOrder(call, callback) { const userContext = extractGrpcContext(call);
if (!userContext.userId) { return callback({ code: grpc.status.UNAUTHENTICATED, message: 'No user context provided' }); }
// Check permission if (!userContext.permissions.includes('orders:create')) { return callback({ code: grpc.status.PERMISSION_DENIED, message: 'Insufficient permissions' }); }
const order = await saveOrder({ ...call.request, createdBy: userContext.userId });
callback(null, order);}
module.exports = { createOrder };Option 3: Message Queue Context
For async processing, include user context in the message payload.
async function publishOrderEvent(userContext, orderData) { const message = { type: 'ORDER_CREATED', data: orderData, metadata: { timestamp: new Date().toISOString(), traceId: generateTraceId() }, // Include user context in every message userContext: { userId: userContext.userId, roles: userContext.roles, permissions: userContext.permissions } };
await messageQueue.publish('orders.events', message);}
module.exports = { publishOrderEvent };async function handleOrderEvent(message) { const { userContext, data } = message;
// Verify context exists if (!userContext?.userId) { console.error('No user context in message', { messageId: message.id }); return; }
// Process with user awareness await reserveInventory({ orderId: data.orderId, items: data.items, reservedBy: userContext.userId });
// Audit trail await logEvent({ action: 'inventory_reserved', userId: userContext.userId, orderId: data.orderId });}
module.exports = { handleOrderEvent };Why This Pattern Matters
Single source of truth. The gateway is the only place that verifies tokens. Change your JWT library or rotation strategy in one place.
Performance. Token verification happens once per request, not once per service call.
Flexibility. Switch from JWT to OAuth2 to API keys at the gateway level without touching downstream services.
Auditability. Every service knows who made the request without implementing authentication themselves.
Common Mistakes to Avoid
Mistake #1: Passing the raw JWT token in headers
I almost did this. The gateway forwards Authorization: Bearer <token>, and each service decodes it. This defeats the purpose of having a gateway.
Mistake #2: Forgetting role and permission information
Just passing userId isn’t enough. Services need to authorize actions, which requires roles and permissions.
Mistake #3: Inconsistent format
Some services expect x-user-id, others expect x-user-Id or user-id. Standardize the format across all services.
Mistake #4: Trusting without verification
The gateway is the trust boundary. Ensure your internal network is secure, or consider signing the forwarded headers with HMAC.
Security Consideration: Signing Forwarded Headers
If your internal network isn’t fully trusted, sign the context headers.
const crypto = require('crypto');
function signUserContext(userContext) { const payload = JSON.stringify(userContext); const signature = crypto .createHmac('sha256', process.env.INTERNAL_SECRET) .update(payload) .digest('hex');
return { context: payload, signature: signature };}
function addSignedHeaders(headers, userContext) { const { context, signature } = signUserContext(userContext);
headers['x-user-context'] = context; headers['x-user-signature'] = signature;
return headers;}
module.exports = { signUserContext, addSignedHeaders };const crypto = require('crypto');
function verifyUserContext(contextHeader, signatureHeader) { const expectedSignature = crypto .createHmac('sha256', process.env.INTERNAL_SECRET) .update(contextHeader) .digest('hex');
if (expectedSignature !== signatureHeader) { throw new Error('Invalid context signature'); }
return JSON.parse(contextHeader);}
function extractAndVerifyContext(req, res, next) { try { const context = verifyUserContext( req.headers['x-user-context'], req.headers['x-user-signature'] );
req.user = context; next(); } catch (error) { res.status(401).json({ error: 'Invalid user context' }); }}
module.exports = { verifyUserContext, extractAndVerifyContext };This adds overhead but protects against header injection if an attacker gains access to your internal network.
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:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments