DEV Community

Team Cargoffer
Team Cargoffer

Posted on

Authentication Patterns for Multi-Tenant Microservices in TRANSCEND

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:

  1. Local verification first (fast path)
  2. 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;
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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_KEY environment variable
  • URL Pattern: https://<service>.transcend.cargoffer.com/api (prod) or https://<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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

TRANSCEND's authentication ecosystem showcases several patterns that are valuable for any multi-tenant microservices architecture:

  1. Centralized token issuance with local verification + fallback
  2. Payload normalization to handle token evolution
  3. Multi-tenant isolation using a combination of JWT claims and profile data
  4. Service-to-service authentication with shared internal keys
  5. 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)