Authentication Patterns for Multi-Tenant Microservices in TRANSCEND
When building a logistics platform like TRANSCEND, you're not just building one service—you're building dozens of microservices that need to talk to each other securely. In this post, we'll dive into the authentication patterns that keep TRANSCEND's ecosystem safe and scalable.
The Problem: Multiple Services, One Identity
TRANSCEND consists of over 15 microservices (POI, Stations, Toll, Drivers, Vehicles, Pay, IAM, etc.). Each service needs to verify that incoming requests are legitimate and identify the user making the request.
The naive approach would be to have each service validate JWTs directly with its own secret—but this creates a coupling problem: when you rotate secrets, you have to update every service.
TRANSCEND's Solution: IAM as the Source of Truth
TRANSCEND uses a pattern where IAM (Identity and Access Management) is the sole issuer of JWTs, and other services verify tokens through a two-step process:
- Local verification first (fast path)
- IAM fallback (slow path, but source of truth)
Code Pattern: auth.middleware.ts
// 1. Try local verification first
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'NoJoke.Brother');
req.user = decoded;
// 2. Fallback: verify via IAM service
const iamUser = await verifyViaIAM(token); // GET /auth/validate-token
if (iamUser) req.user = iamUser;
This gives you the best of both worlds: fast local validation for most requests, with a safety net that ensures tokens issued by IAM are always accepted—even if a service's JWT_SECRET is temporarily out of sync.
JWT Payload Normalization: Handling Legacy vs Real Tokens
One of the sneakiest bugs in microservices is assuming all JWTs have the same payload structure. TRANSCEND learned this the hard way when migrating from local development tokens to production IAM tokens.
The Difference
| Field | Legacy Dev JWT | Real IAM JWT |
|---|---|---|
userId |
✅ Present | ❌ Missing |
_id |
❌ Missing | ✅ Present |
companyId |
✅ Present | ❌ Missing |
accountType |
❌ Missing | ✅ Present |
subscription_type |
✅ Present | ❌ Missing |
subscriptionStatus |
❌ Missing | ✅ Present |
The Fix: normalizePayload()
TRANSCEND uses a normalization function that works with both formats:
function normalizePayload(raw: Record<string, any>): TokenDecoded {
const uid = raw.userId || raw._id || raw.sub || '';
return {
userId: typeof uid === 'object' ? String(uid) : uid,
_id: raw._id || raw.userId || uid,
email: raw.email || '',
role: raw.role || 'user',
companyId: raw.companyId || raw.company_id || undefined,
iat: raw.iat || Math.floor(Date.now() / 1000),
exp: raw.exp || Math.floor(Date.now() / 1000) + 3600,
};
}
This function is used both in local JWT verification and when processing the response from IAM's /auth/validate-token endpoint.
Multi-Tenant Isolation: The companyId Problem
TRANSCEND is multi-tenant: each driver, vehicle, and load belongs to a company. But the IAM JWT doesn't contain a companyId field—it contains accountType (which can be "multitenant" or "dedicated").
So how do services know which company a user belongs to?
Answer: The companyId is stored in the user's profile in the IAM database, and services must fetch it when needed.
The Three-Layer Auth Pattern
Every TRANSCEND service that checks ownership uses this pattern:
// LIST — admin sees all, regular user sees own + company's
if (owner && req.user?.role === 'admin') {
if (req.query.companyId) q.companyId = req.query.companyId;
} else if (owner) {
const companyId = req.user?.companyId;
if (companyId) q.$or = [{ owner }, { companyId }];
else q.owner = owner;
}
// GET / UPDATE / DELETE — admin can access any, regular user only own
if (req.user?.userId && req.user?.role !== 'admin' && String(doc.owner) !== String(req.user.userId)) {
return returnKO(res, 403, 'Forbidden');
}
Note that req.user?.companyId is populated by fetching the user's profile from IAM when the user logs in or on first request.
Service-to-Service Auth: The x-api-key Pattern
When one TRANSCEND service calls another (e.g., the Route service calling the Toll service), they don't use JWTs—they use a shared internal API key.
The Convention
-
Header:
x-api-key(case-insensitive) -
Value: The
INTERNAL_API_KEYenvironment variable -
URL Pattern:
https://<service>.transcend.cargoffer.com/api(prod) orhttps://<service>-release.transcend.cargoffer.com/api(staging)
Why Not JWT?
Service-to-service calls are frequent and low-latency. Validating a JWT on every internal call would add overhead. The API key pattern is:
- Simple to implement
- Fast (just a string comparison)
- Easy to rotate (update the env var and restart)
Migration Pattern: Destructuring with Alias
Many services still reference SYS_API_KEY in their code. The migration pattern is:
// Before
const { TRANSCEND_TOLLS_URL, SYS_API_KEY } = process.env;
// After (zero-downtime)
const { TRANSCEND_TOLLS_URL, INTERNAL_API_KEY: SYS_API_KEY } = process.env;
This reads the canonical INTERNAL_API_KEY and aliases it to the old SYS_API_KEY name, so existing code continues to work.
Pitfalls & Lessons Learned
1. JWT_SECRET Sync is Critical
If IAM and a microservice have different JWT_SECRET values, the local verification will fail and fall back to IAM—but if the fallback is misconfigured, you get authentication loops.
Fix: Always copy IAM's JWT_SECRET to new services during deployment.
2. Global Axios Interceptors Cause Logout
TRANSCEND's frontend has a global axios interceptor that logs out the user on any 401. If a secondary service (like Tacograph) returns 401 due to misconfiguration, the user gets logged out of the entire system.
Fix: Use local axios instances for non-IAM services:
const localAxios = axios.create(); // No global interceptors
3. .env Files Are Easy to Misconfigure
Because .env files are gitignored, it's easy to drift between environments.
Fix: Include API key validation in your health check endpoint:
app.get('/health', (req, res) => {
if (!process.env.INTERNAL_API_KEY) {
return res.status(500).json({ status: 'error', message: 'Missing INTERNAL_API_KEY' });
}
res.json({ status: 'ok' });
});
Conclusion
TRANSCEND's authentication ecosystem showcases several patterns that are valuable for any multi-tenant microservices architecture:
- Centralized token issuance with local verification + fallback
- Payload normalization to handle token evolution
- Multi-tenant isolation using a combination of JWT claims and profile data
- Service-to-service authentication with shared internal keys
- Zero-downtime migration patterns for legacy code
These patterns have kept TRANSCEND secure and scalable as it grew from a single service to a complex logistics platform.
This is part of the "TRANSCEND Architecture Deep Dive" series. Next: "Building a Geocoding Service with Points of Interest API".
Top comments (0)