Skip to content

Middleware Implementation

This document is FINAL and ENFORCEABLE for backend middleware implementation.

It translates the architecture and access-control rules into practical code patterns.

It complements:

  • 2.1.-Backend-Auth.md
  • 2.2.-Backend-Core.md
  • 2.2.1.-Backend-Core-API.md
  • 2.3.-Backend-Base.md
  • 2.4.-Access-Control-Integration.md
  • 2.5.-Access-Matrix.md

This document defines the actual backend middleware pattern for:

  • JWT validation
  • x-org enforcement
  • Auth access resolution
  • module enforcement
  • permission enforcement
  • fail-closed behavior
  • request context propagation

It includes:

  • Node.js / Express-style examples
  • Go / Chi-style examples
  • reusable guard helpers:
    • requireModule()
    • requirePermission()

All tenant-scoped backend routes must follow this flow:

  1. validate JWT
  2. validate x-org
  3. resolve effective access from Auth
  4. verify module
  5. verify permission
  6. execute or deny

Backends do not compute access.
Backends do not call Core for authorization.
Backends do not trust frontend state.
Backends enforce Auth-resolved access only.

Middleware assumes Auth exposes an access-resolution contract like:

GET /auth/me/access
Authorization: Bearer <USER_ACCESS_TOKEN>
x-org: <COMPANY_ID>
X-Internal-API-Key: <SERVICE_KEY>

And returns a shape similar to:

{
"userId": "uuid",
"companyId": "uuid",
"tenantRole": "ADMIN",
"modules": ["basic", "finance"],
"permissions": [
"basic.dashboard.view",
"finance.expense.view"
],
"delegation": {
"canManageUsers": true,
"canBuyAddons": false
},
"meta": {
"tokenVersion": 2,
"entitlementVersion": 8
}
}

Your backend should attach a resolved request context after middleware succeeds.

Recommended normalized shape:

type RequestAccessContext = {
userId: string
companyId: string
tenantRole: string | null
modules: string[]
permissions: string[]
delegation?: {
canManageUsers?: boolean
canBuyAddons?: boolean
grantableModules?: string[]
grantablePermissions?: string[]
}
meta?: {
tokenVersion?: number
entitlementVersion?: number
generatedAt?: string
}
}

Equivalent idea in Go:

type AccessContext struct {
UserID string
CompanyID string
TenantRole string
Modules []string
Permissions []string
Delegation map[string]any
Meta map[string]any
}

Use these rules consistently:

  • 401 → invalid/missing JWT
  • 400 → missing/invalid x-org
  • 403 → access denied (missing module / permission / membership)
  • 503 → Auth access resolution unavailable

Below is a practical Node.js implementation pattern.


import type { Request, Response, NextFunction } from "express";
export type AccessContext = {
userId: string;
companyId: string;
tenantRole?: string | null;
modules: string[];
permissions: string[];
delegation?: {
canManageUsers?: boolean;
canBuyAddons?: boolean;
grantableModules?: string[];
grantablePermissions?: string[];
};
meta?: {
tokenVersion?: number;
entitlementVersion?: number;
generatedAt?: string;
};
};
export interface AuthenticatedRequest extends Request {
jwt?: {
sub: string;
rawToken: string;
};
access?: AccessContext;
}

function sendError(
res: Response,
status: number,
code: string,
message: string
) {
return res.status(status).json({
success: false,
error: { code, message },
});
}

This middleware validates the token format and signature locally.

In production you would wire this to JWKS / Auth public keys.

import jwt from "jsonwebtoken";
const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY?.replace(/\\n/g, "\n") ?? "";
const JWT_ISSUER = process.env.JWT_ISSUER ?? "auth.kisum.io";
const JWT_AUDIENCE = process.env.JWT_AUDIENCE ?? "kisum-apps";
export function validateJwt() {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const auth = req.header("authorization");
if (!auth || !auth.startsWith("Bearer ")) {
return sendError(res, 401, "unauthorized", "missing bearer token");
}
const rawToken = auth.slice("Bearer ".length).trim();
if (!rawToken) {
return sendError(res, 401, "unauthorized", "missing bearer token");
}
const decoded = jwt.verify(rawToken, JWT_PUBLIC_KEY, {
algorithms: ["RS256"],
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
}) as jwt.JwtPayload;
if (!decoded.sub || typeof decoded.sub !== "string") {
return sendError(res, 401, "unauthorized", "invalid token subject");
}
req.jwt = {
sub: decoded.sub,
rawToken,
};
return next();
} catch (err) {
return sendError(res, 401, "unauthorized", "invalid or expired token");
}
};
}

export function requireCompanyContext() {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const xOrg = req.header("x-org");
if (!xOrg || !xOrg.trim()) {
return sendError(res, 400, "validation_error", "missing x-org header");
}
// Optional UUID format check here if required.
return next();
};
}

type AuthAccessResponse = {
success: boolean;
data?: AccessContext;
error?: {
code: string;
message: string;
};
};
const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? "https://auth.kisum.io";
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY ?? "";
export async function fetchAccessContext(
rawToken: string,
companyId: string
): Promise<AccessContext> {
const response = await fetch(`${AUTH_BASE_URL}/auth/me/access`, {
method: "GET",
headers: {
Authorization: `Bearer ${rawToken}`,
"x-org": companyId,
"X-Internal-API-Key": INTERNAL_API_KEY,
Accept: "application/json",
},
});
if (response.status === 401) {
throw Object.assign(new Error("invalid token"), { status: 401 });
}
if (response.status === 403) {
throw Object.assign(new Error("forbidden"), { status: 403 });
}
if (response.status >= 500) {
throw Object.assign(new Error("auth unavailable"), { status: 503 });
}
const body = (await response.json()) as AuthAccessResponse;
if (!body.success || !body.data) {
throw Object.assign(new Error("access resolution failed"), { status: 503 });
}
return body.data;
}

export function resolveAccessContext() {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
if (!req.jwt?.rawToken) {
return sendError(res, 401, "unauthorized", "missing validated JWT");
}
const companyId = req.header("x-org");
if (!companyId) {
return sendError(res, 400, "validation_error", "missing x-org header");
}
const access = await fetchAccessContext(req.jwt.rawToken, companyId);
req.access = access;
return next();
} catch (err: any) {
if (err?.status === 401) {
return sendError(res, 401, "unauthorized", "invalid or expired token");
}
if (err?.status === 403) {
return sendError(res, 403, "forbidden", "access forbidden");
}
return sendError(
res,
503,
"service_unavailable",
"unable to resolve access from auth"
);
}
};
}

export function requireModule(moduleKey: string) {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const modules = req.access?.modules ?? [];
if (!modules.includes(moduleKey)) {
return sendError(
res,
403,
"forbidden",
`missing module access: ${moduleKey}`
);
}
return next();
};
}

export function requirePermission(permissionKey: string) {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const permissions = req.access?.permissions ?? [];
if (!permissions.includes(permissionKey)) {
return sendError(
res,
403,
"forbidden",
`missing permission: ${permissionKey}`
);
}
return next();
};
}

import express from "express";
const app = express();
app.get(
"/api/finance/expenses",
validateJwt(),
requireCompanyContext(),
resolveAccessContext(),
requireModule("finance"),
requirePermission("finance.expense.view"),
async (_req, res) => {
return res.json({
success: true,
data: {
items: [],
},
});
}
);
app.post(
"/api/finance/expenses",
validateJwt(),
requireCompanyContext(),
resolveAccessContext(),
requireModule("finance"),
requirePermission("finance.expense.create"),
async (_req, res) => {
return res.status(201).json({
success: true,
data: {
created: true,
},
});
}
);

export function requireAccess(moduleKey: string, permissionKey: string) {
return [
validateJwt(),
requireCompanyContext(),
resolveAccessContext(),
requireModule(moduleKey),
requirePermission(permissionKey),
];
}

Usage:

app.get(
"/api/basic/events",
...requireAccess("basic", "basic.event.view"),
async (_req, res) => {
return res.json({ success: true, data: [] });
}
);

Below is a practical Go pattern for services using Chi.


package access
type AccessContext struct {
UserID string `json:"userId"`
CompanyID string `json:"companyId"`
TenantRole string `json:"tenantRole"`
Modules []string `json:"modules"`
Permissions []string `json:"permissions"`
Delegation map[string]any `json:"delegation,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}

package access
type contextKey string
const (
CtxJWTTokenKey contextKey = "jwt_raw_token"
CtxUserIDKey contextKey = "user_id"
CtxCompanyIDKey contextKey = "company_id"
CtxAccessKey contextKey = "access_context"
)

package access
import (
"encoding/json"
"net/http"
)
func WriteError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"error": map[string]any{
"code": code,
"message": message,
},
})
}

This example assumes you already have a JWT validation function.

package access
import (
"context"
"net/http"
"strings"
)
type ValidatedJWT struct {
Subject string
RawToken string
}
func VerifyJWT(rawToken string) (*ValidatedJWT, error) {
// Plug in your real RS256/JWKS verification here.
// Must validate signature, exp, iss, aud.
return &ValidatedJWT{
Subject: "user-subject",
RawToken: rawToken,
}, nil
}
func ValidateJWT(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
WriteError(w, http.StatusUnauthorized, "unauthorized", "missing bearer token")
return
}
rawToken := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if rawToken == "" {
WriteError(w, http.StatusUnauthorized, "unauthorized", "missing bearer token")
return
}
validated, err := VerifyJWT(rawToken)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), CtxJWTTokenKey, validated.RawToken)
ctx = context.WithValue(ctx, CtxUserIDKey, validated.Subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

package access
import (
"context"
"net/http"
"strings"
)
func RequireCompanyContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
companyID := strings.TrimSpace(r.Header.Get("x-org"))
if companyID == "" {
WriteError(w, http.StatusBadRequest, "validation_error", "missing x-org header")
return
}
ctx := context.WithValue(r.Context(), CtxCompanyIDKey, companyID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

package access
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type AuthClient struct {
BaseURL string
InternalAPIKey string
HTTPClient *http.Client
}
func NewAuthClient(baseURL, internalAPIKey string) *AuthClient {
return &AuthClient{
BaseURL: baseURL,
InternalAPIKey: internalAPIKey,
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *AuthClient) FetchAccess(rawToken, companyID string) (*AccessContext, int, error) {
req, err := http.NewRequest(http.MethodGet, c.BaseURL+"/auth/me/access", nil)
if err != nil {
return nil, http.StatusServiceUnavailable, err
}
req.Header.Set("Authorization", "Bearer "+rawToken)
req.Header.Set("x-org", companyID)
req.Header.Set("X-Internal-API-Key", c.InternalAPIKey)
req.Header.Set("Accept", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, http.StatusServiceUnavailable, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, http.StatusUnauthorized, fmt.Errorf("unauthorized")
}
if resp.StatusCode == http.StatusForbidden {
return nil, http.StatusForbidden, fmt.Errorf("forbidden")
}
if resp.StatusCode >= 500 {
return nil, http.StatusServiceUnavailable, fmt.Errorf("auth unavailable")
}
var body struct {
Success bool `json:"success"`
Data AccessContext `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, http.StatusServiceUnavailable, err
}
if !body.Success {
return nil, http.StatusServiceUnavailable, fmt.Errorf("access resolution failed")
}
return &body.Data, http.StatusOK, nil
}

package access
import (
"context"
"net/http"
)
func ResolveAccessContext(client *AuthClient) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawToken, _ := r.Context().Value(CtxJWTTokenKey).(string)
companyID, _ := r.Context().Value(CtxCompanyIDKey).(string)
if rawToken == "" {
WriteError(w, http.StatusUnauthorized, "unauthorized", "missing validated JWT")
return
}
if companyID == "" {
WriteError(w, http.StatusBadRequest, "validation_error", "missing x-org header")
return
}
access, status, err := client.FetchAccess(rawToken, companyID)
if err != nil {
switch status {
case http.StatusUnauthorized:
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token")
case http.StatusForbidden:
WriteError(w, http.StatusForbidden, "forbidden", "access forbidden")
default:
WriteError(w, http.StatusServiceUnavailable, "service_unavailable", "unable to resolve access from auth")
}
return
}
ctx := context.WithValue(r.Context(), CtxAccessKey, access)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

package access
import "net/http"
func RequireModule(moduleKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
access, _ := r.Context().Value(CtxAccessKey).(*AccessContext)
if access == nil {
WriteError(w, http.StatusServiceUnavailable, "service_unavailable", "missing access context")
return
}
for _, m := range access.Modules {
if m == moduleKey {
next.ServeHTTP(w, r)
return
}
}
WriteError(w, http.StatusForbidden, "forbidden", "missing module access: "+moduleKey)
})
}
}

package access
import "net/http"
func RequirePermission(permissionKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
access, _ := r.Context().Value(CtxAccessKey).(*AccessContext)
if access == nil {
WriteError(w, http.StatusServiceUnavailable, "service_unavailable", "missing access context")
return
}
for _, p := range access.Permissions {
if p == permissionKey {
next.ServeHTTP(w, r)
return
}
}
WriteError(w, http.StatusForbidden, "forbidden", "missing permission: "+permissionKey)
})
}
}

package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"yourapp/access"
)
func main() {
authClient := access.NewAuthClient(
"https://auth.kisum.io",
"your-internal-service-key",
)
r := chi.NewRouter()
r.Route("/api/finance", func(r chi.Router) {
r.Use(access.ValidateJWT)
r.Use(access.RequireCompanyContext)
r.Use(access.ResolveAccessContext(authClient))
r.With(
access.RequireModule("finance"),
access.RequirePermission("finance.expense.view"),
).Get("/expenses", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"data": []any{},
})
})
r.With(
access.RequireModule("finance"),
access.RequirePermission("finance.expense.create"),
).Post("/expenses", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"data": map[string]any{
"created": true,
},
})
})
})
_ = http.ListenAndServe(":8080", r)
}

Caching is optional but recommended for performance.

Cache must never become the source of truth.

Use cache only if:

  • userId matches
  • companyId matches
  • tokenVersion matches
  • entitlementVersion matches (if available)

If Auth is unavailable:

  • do not allow requests from stale cache blindly
  • fail closed with 503

type CachedAccess = AccessContext & {
cacheKey: string;
};
function buildAccessCacheKey(
userId: string,
companyId: string,
tokenVersion?: number,
entitlementVersion?: number
) {
return `access:${userId}:${companyId}:${tokenVersion ?? 0}:${entitlementVersion ?? 0}`;
}

func BuildAccessCacheKey(userID, companyID string, tokenVersion, entitlementVersion int) string {
return fmt.Sprintf("access:%s:%s:%d:%d", userID, companyID, tokenVersion, entitlementVersion)
}

For tenant-scoped routes, use this order:

  1. validateJwt
  2. requireCompanyContext
  3. resolveAccessContext
  4. requireModule
  5. requirePermission

This ensures:

  • identity is validated first
  • company context is validated second
  • access is resolved once
  • guards run on resolved context

Middleware must never:

  • call Platform Core for user authorization
  • compute entitlements ∩ grants
  • trust frontend access claims
  • allow optimistic fallback if Auth fails
  • allow stale access silently when validation signals mismatch
  • derive module access from role alone

Requires:

  • module: basic
  • permission: basic.dashboard.view

Requires:

  • module: basic
  • permission: basic.event.view

Requires:

  • module: finance
  • permission: finance.expense.view

Requires:

  • module: finance
  • permission: finance.expense.create

Requires:

  • module: market
  • permission: market.contract.approve

You should test at minimum:

  • missing token → 401
  • invalid token → 401
  • missing x-org400
  • Auth unavailable → 503
  • user not in company → 403
  • module not granted → 403
  • permission not granted → 403
  • valid token + valid company + valid module + valid permission → request succeeds

Auth resolves access.
Middleware enforces access.
Handlers execute business logic only after access succeeds.

A backend route is allowed only after JWT validation, company resolution, Auth access resolution, module check, and permission check all succeed.