
Part 4: Building the Zero-Trust Node.js Gateway & State Cache
We construct the off-chain engine of our API gateway using Node.js and Express. This post details how to completely eliminate centralized JWTs by building custom middleware that mathematically verifies Ed25519 cryptographic signatures on every request. We also implement a memory-safe TTL State Cache Manager with garbage collection to rapidly resolve on-chain session delegation states without hitting public RPC rate limits.
5) Node Backend (The Zero-Trust Gateway)
Now that the smart contract is deployed to handle the ultimate source of truth for RBAC (subscription tiers) and ephemeral key delegation, we need to build the off-chain infrastructure.
We are setting up an Express application running on a Node.js server. This backend acts as a high-speed, stateless proxy between the user and the protected API endpoints. It performs two critical functions:
- Cryptographic Validation: It intercepts all API requests and mathematically verifies the Ed25519 signatures, protecting against replay attacks via a strict timestamp window.
- The State Cache: It queries the Movement blockchain (a read-only RPC call) to verify if the ephemeral public key has been explicitly authorized by a primary wallet. To avoid hitting RPC rate limits by pinging the blockchain on every single API request, the backend caches the authorized tier in a local
StateCacheManagerfor 5 minutes.
By doing this, all subsequent API requests for the next 5 minutes are validated almost entirely in local server memory. This architecture completely eliminates centralized Server Secrets (like JWTs) and ensures every request is mathematically secure.
5.1) Shared Backend Config (DRY)
All backend modules import the Aptos client and contract address from a single config file. This eliminates hardcoded RPC URLs and duplicated CONTRACT_ADDRESS normalization.
Path: packages/backend/src/config/aptos.ts
Code:
import { Aptos, AptosConfig, Network } from '@aptos-labs/ts-sdk';
export const RPC_URL = process.env.RPC_URL || 'https://testnet.movementnetwork.xyz/v1';
const RAW_CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS || '';
export const CONTRACT_ADDRESS = RAW_CONTRACT_ADDRESS.startsWith('0x')
? RAW_CONTRACT_ADDRESS
: `0x${RAW_CONTRACT_ADDRESS}`;
const aptosConfig = new AptosConfig({ network: Network.CUSTOM, fullnode: RPC_URL });
export const aptos = new Aptos(aptosConfig);
Why? Single source of truth — if the contract gets redeployed or we switch networks, we only update one file.
5.2) Web3 Gateway Middleware
This interceptor is the core of our Zero-Trust architecture. Note the Map-based TTL cache and garbage collector.
Path: packages/backend/src/middleware/web3Gateway.ts
Code:
import { Request, Response, NextFunction } from 'express';
import { Ed25519PublicKey, Ed25519Signature } from '@aptos-labs/ts-sdk';
import { aptos, CONTRACT_ADDRESS } from '../config/aptos';
declare global {
namespace Express {
interface Request {
web3Context?: {
wallet: string;
tier: number;
};
}
}
}
// ---------------------------------------------------------
// STATE CACHE MANAGER
// ---------------------------------------------------------
interface CacheEntry {
ephemeralPublicKeyHex: string;
expiresAt: number; // On-chain expiration timestamp
tier: number; // On-chain subscription tier
cachedAt: number; // Local cache timestamp
}
// Memory leak protection: Simple Map with TTL
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 Minutes
const stateCache = new Map<string, CacheEntry>();
// Garbage Collector: Clear expired cache entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [wallet, entry] of stateCache.entries()) {
if (now - entry.cachedAt > CACHE_TTL_MS) {
stateCache.delete(wallet);
}
}
}, CACHE_TTL_MS);
export const requireSignatureAuth = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const pubKeyHex = req.headers['x-auth-pubkey'] as string;
const signatureHex = req.headers['x-auth-signature'] as string;
const timestampStr = req.headers['x-auth-timestamp'] as string;
const walletAddress = req.headers['x-wallet-address'] as string;
if (!pubKeyHex || !signatureHex || !timestampStr || !walletAddress) {
res.status(401).json({ error: 'Unauthorized', message: 'Missing cryptographic payload headers.' });
return;
}
// 1. Replay Attack Prevention (60-second window)
const requestTime = parseInt(timestampStr, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - requestTime) > 60) {
res.status(401).json({ error: 'Unauthorized', message: 'Signature expired (replay protection).' });
return;
}
try {
// 2. Verify Cryptographic Signature Mathematically
const messageToSign = `${req.method}:${req.originalUrl || req.url}:${timestampStr}`;
const messageBytes = new TextEncoder().encode(messageToSign);
// Normalize hex inputs
let publicKeyHexClean = pubKeyHex.startsWith('0x') ? pubKeyHex.slice(2) : pubKeyHex;
let signatureHexClean = signatureHex.startsWith('0x') ? signatureHex.slice(2) : signatureHex;
const publicKey = new Ed25519PublicKey(publicKeyHexClean);
const signature = new Ed25519Signature(signatureHexClean);
if (!publicKey.verifySignature({ message: messageBytes, signature })) {
res.status(401).json({ error: 'Unauthorized', message: 'Invalid cryptographic signature.' });
return;
}
// 3. Resolve On-Chain Delegation State
let tier = 0;
const nowMs = Date.now();
const cachedState = stateCache.get(walletAddress);
const requestKeyStr = pubKeyHex.startsWith('0x') ? pubKeyHex : `0x${pubKeyHex}`;
if (cachedState && (nowMs - cachedState.cachedAt) < CACHE_TTL_MS) {
// Cache HIT
if (cachedState.ephemeralPublicKeyHex.toLowerCase() !== requestKeyStr.toLowerCase()) {
res.status(403).json({ error: 'Forbidden', message: 'Session key mismatch. Not authorized by primary wallet.' });
return;
}
if (cachedState.expiresAt <= currentTime) {
res.status(403).json({ error: 'Forbidden', message: 'Session key has expired on-chain.' });
return;
}
tier = cachedState.tier;
} else {
// Cache MISS - Query the blockchain
let sessionData: any = null;
try {
const sessionResource = await aptos.getAccountResource({
accountAddress: walletAddress,
resourceType: `${CONTRACT_ADDRESS}::api_gateway::SessionKey` as any,
});
sessionData = sessionResource;
} catch (e: any) {
if (e?.status === 404) {
res.status(403).json({ error: 'Forbidden', message: 'No session key delegation found on-chain for this wallet.' });
return;
}
throw e;
}
const onChainKeyHex = sessionData.ephemeral_public_key;
const onChainKeyStr = onChainKeyHex.startsWith('0x') ? onChainKeyHex : `0x${onChainKeyHex}`;
if (onChainKeyStr.toLowerCase() !== requestKeyStr.toLowerCase()) {
res.status(403).json({ error: 'Forbidden', message: 'Session key mismatch. Not authorized by primary wallet.' });
return;
}
const sessionExpiresAt = parseInt(sessionData.expires_at, 10);
if (sessionExpiresAt <= currentTime) {
res.status(403).json({ error: 'Forbidden', message: 'Session key has expired on-chain.' });
return;
}
// Check Tier Subscription
try {
const subResource = await aptos.getAccountResource({
accountAddress: walletAddress,
resourceType: `${CONTRACT_ADDRESS}::api_gateway::Subscription` as any,
});
const subExpiresAt = parseInt((subResource as any).expires_at, 10);
if (subExpiresAt > currentTime) {
tier = (subResource as any).tier;
}
} catch (e) {
// Fallback to tier 0
}
// Populate Cache
stateCache.set(walletAddress, {
ephemeralPublicKeyHex: requestKeyStr,
expiresAt: sessionExpiresAt,
tier: tier,
cachedAt: nowMs
});
}
// 4. Authorized! Inject trusted state
req.web3Context = {
wallet: walletAddress,
tier: tier,
};
next();
} catch (error: any) {
console.error('Auth Error:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
};
5.3) Server & Stateless Routing
The Express app consumes the Web3 Gateway Middleware and exposes the secure endpoints.
Path: packages/backend/src/server.ts
Code:
import express from 'express';
import cors from 'cors';
import { requireSignatureAuth } from './middleware/web3Gateway';
const app = express();
app.use(cors());
app.use(express.json());
const PORT = process.env.PORT || 8080;
const rateLimitStore = new Map<string, number>();
// Garbage Collector: Clears the rate limit memory every 60 seconds
// This prevents the Map from causing a memory leak over time.
setInterval(() => {
rateLimitStore.clear();
}, 60000);
// --- Public Routes ---
app.get('/api/health', (req, res) => {
res.json({ status: 'OK' });
});
// Removed legacy JWT /api/auth/login route. Zero-trust architecture validates on every request.
// --- Protected Routes ---
// Note: We swapped requireJwtAuth for requireSignatureAuth
app.get('/api/secure-data', requireSignatureAuth, (req, res) => {
const { wallet, tier } = req.web3Context!;
const currentWindow = Math.floor(Date.now() / 10000);
const requestKey = `${wallet}:${currentWindow}`;
const requestCount = (rateLimitStore.get(requestKey) || 0) + 1;
rateLimitStore.set(requestKey, requestCount);
if (tier === 0 && requestCount > 2) {
return res.status(429).json({ error: 'Too Many Requests' });
}
if (tier === 1 && requestCount > 10) {
return res.status(429).json({ error: 'Too Many Requests' });
}
res.json({
status: 'Success',
message: 'Data secured by zero-trust Ephemeral Session Key signature validation.',
data: { userTier: tier, financialData: [100, 250, 400] }
});
});
app.listen(PORT, () => {
console.log(`🚀 Gateway routing traffic on http://localhost:${PORT}`);
});
What's Next?
With our Node.js gateway actively intercepting requests, verifying mathematical signatures, and caching state, our zero-trust backend is full working
In Part 5, the final chapter of this series, we bring it all together by building a React frontend to visualise everything. We'll set up Tailwind CSS v4 for a sleek, utility-first design, create the Web3 handshake to natively generate our in-memory ephemeral keys, and build a real-time console to visualize our cryptographic requests. We will wire up the UI, start the local servers, and see the completed zero-trust architecture in action.
Community Discussion
0 Comments
Found this helpful?
If you enjoyed this technical tale, consider supporting my work.