






















Your billing service needs to call the user service to check an account status. The call goes over an internal network, not the public internet, so it is probably fine to skip auth, right? Then someone on the infrastructure slack asks “hey, is anyone’s service making requests to the user admin endpoint?” and nobody fesses up, and you realize that any container on the same Kubernetes cluster can call any other service’s HTTP endpoint without proving who it is.
This is the default state of most internal service meshes. It is also the problem that AWS solved with SigV4, Google Cloud solved with OAuth2 access tokens, and you can solve in about sixty lines of TypeScript with HMAC request signing. No JWT issuer to maintain. No CA to manage. No TLS renegotiation. Just a shared secret, a cryptographic signature over the request, and a timestamp that keeps the whole thing fresh.
Every outgoing HTTP request from Service A to Service B carries an extra header:
Authorization: HMAC-SHA256 credential=service-a,
signed-headers=host;x-date;content-hash,
signature=5d4c8a2e...
The receiving service recomputes the signature from the shared secret and the exact same set of request components (method, path, headers, body hash). If the signatures match, the request is authentic. If the timestamp is too old, it is a replay. If the body was tampered in transit, the content hash will not match and the signature fails.
The whole thing takes three pieces: a shared secret store, a signing function on the caller side, and a verification middleware on the receiver side.
Every service pair needs a secret that only those two services know. The simplest approach is a map in an encrypted environment variable:
// secrets.ts
interface ServiceSecrets {
[serviceName: string]: Buffer;
}
// Loaded from env, e.g. SERVICE_SECRETS='{"user-service":"hex-encoded-256-bit-key","payment-service":"..."}'
function loadSecrets(): ServiceSecrets {
const raw = process.env.SERVICE_SECRETS;
if (!raw) throw new Error('SERVICE_SECRETS not set');
const parsed = JSON.parse(raw);
const secrets: ServiceSecrets = {};
for (const [svc, key] of Object.entries(parsed)) {
secrets[svc] = Buffer.from(key as string, 'hex');
}
return secrets;
}
Each key should be a 256-bit (32-byte) cryptographically random value. Generate them with:
openssl rand -hex 32
Store these in your secrets manager (Vault, AWS Secrets Manager, or SOPS-encrypted files). Do not commit them to git. Do not share the same key across three services.
The caller side takes a request object and produces the Authorization header value. Every component of the request that affects the meaning of the call (method, path, host, body) must be included in the signature.
// sign.ts
import { createHash, createHmac, randomBytes } from 'node:crypto';
interface SignOptions {
secret: Buffer;
method: string;
path: string;
host: string;
body?: Buffer | string | object | null;
date?: Date;
}
interface SignResult {
authorization: string;
date: string;
contentHash: string;
}
export function signRequest(opts: SignOptions): SignResult {
const date = (opts.date ?? new Date()).toUTCString();
const bodyBuffer = normalizeBody(opts.body);
const contentHash = createHash('sha256').update(bodyBuffer).digest('hex');
const signedHeaders = 'host;x-date;x-content-sha256';
// Build the string to sign
const canonicalRequest = [
opts.method.toUpperCase(),
opts.path,
`host:${opts.host}`,
`x-date:${date}`,
`x-content-sha256:${contentHash}`,
'', // blank line separating headers
signedHeaders,
contentHash,
].join('\n');
const signature = createHmac('sha256', opts.secret)
.update(canonicalRequest)
.digest('hex');
const credential = process.env.SERVICE_NAME ?? 'unknown';
return {
authorization: [
`HMAC-SHA256 credential=${credential}`,
`signed-headers=${signedHeaders}`,
`signature=${signature}`,
].join(', '),
date,
contentHash,
};
}
function normalizeBody(body: unknown): Buffer {
if (body === null || body === undefined) return Buffer.alloc(0);
if (Buffer.isBuffer(body)) return body;
if (typeof body === 'string') return Buffer.from(body);
return Buffer.from(JSON.stringify(body));
}
The canonical request string is the key design decision. Every byte of it must be reproducible on the receiving side. That means the host must match exactly what the receiver sees, the path must be exactly what was sent (no URL decoding ambiguity), and the body hash must be computed from the raw bytes before any transport encoding.
Now wrap your HTTP client so every outgoing request gets signed automatically:
// client.ts
import { request, RequestOptions } from 'node:http';
interface ServiceConfig {
host: string;
port: number;
secret: Buffer;
}
export function createSignedClient(config: ServiceConfig) {
return async function call(
method: string,
path: string,
body?: unknown
): Promise<{ status: number; body: string }> {
const { authorization, date, contentHash } = signRequest({
secret: config.secret,
method,
path,
host: config.host,
body,
});
return new Promise((resolve, reject) => {
const opts: RequestOptions = {
hostname: config.host,
port: config.port,
path,
method,
headers: {
'x-date': date,
'x-content-sha256': contentHash,
'content-type': body ? 'application/json' : undefined,
authorization,
},
};
const req = request(opts, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode!, body: data }));
});
req.on('error', reject);
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
};
}
Every single outbound call now carries a verifiable proof of who sent it. No chance for a developer to “forget” to add auth to a new endpoint.
On the receiving side, a middleware extracts the signature and recomputes it:
// middleware.ts (Express-style)
import { Request, Response, NextFunction } from 'express';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
interface VerifyOptions {
secrets: Map<string, Buffer>;
clockSkewMs?: number; // default 300000 (5 minutes)
}
export function verifySignature(opts: VerifyOptions) {
const clockSkew = opts.clockSkewMs ?? 300_000;
return (req: Request, res: Response, next: NextFunction) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('HMAC-SHA256 ')) {
res.status(401).json({ error: 'missing or malformed authorization' });
return;
}
// Parse the auth header
const parts = auth.slice('HMAC-SHA256 '.length).split(', ');
const credential = parts[0]?.split('=')[1];
const signedHeaders = parts[1]?.split('=')[1];
const signature = parts[2]?.split('=')[1];
if (!credential || !signedHeaders || !signature) {
res.status(401).json({ error: 'malformed authorization header' });
return;
}
// Look up the secret for this caller
const secret = opts.secrets.get(credential);
if (!secret) {
res.status(401).json({ error: 'unknown credential' });
return;
}
// Check timestamp freshness (replay protection)
const dateStr = req.headers['x-date'] as string | undefined;
if (!dateStr) {
res.status(401).json({ error: 'missing x-date header' });
return;
}
const requestTime = new Date(dateStr).getTime();
const now = Date.now();
if (Number.isNaN(requestTime) || Math.abs(now - requestTime) > clockSkew) {
res.status(401).json({ error: 'request expired or clock skew too large' });
return;
}
// Compute content hash from the raw body
const rawBody = (req as any).rawBody as Buffer | undefined;
const contentHash = createHash('sha256')
.update(rawBody ?? Buffer.alloc(0))
.digest('hex');
const providedHash = req.headers['x-content-sha256'] as string;
if (contentHash !== providedHash) {
res.status(401).json({ error: 'body hash mismatch (tampered in transit?)' });
return;
}
// Rebuild the canonical request
const host = req.headers.host ?? 'localhost';
const canonicalRequest = [
req.method,
req.originalUrl || req.url,
`host:${host}`,
`x-date:${dateStr}`,
`x-content-sha256:${contentHash}`,
'',
signedHeaders,
contentHash,
].join('\n');
const expectedSignature = createHmac('sha256', secret)
.update(canonicalRequest)
.digest('hex');
// Constant-time comparison to prevent timing attacks
const sigBuf = Buffer.from(signature, 'hex');
const expectedBuf = Buffer.from(expectedSignature, 'hex');
if (
sigBuf.length !== expectedBuf.length ||
!timingSafeEqual(sigBuf, expectedBuf)
) {
res.status(401).json({ error: 'signature mismatch' });
return;
}
// Attach verified caller identity to the request
(req as any).verifiedCaller = credential;
next();
};
}
This middleware needs access to the raw request body before Express parses it, because JSON parsing can change whitespace. Wire it up like this:
// app.ts
import express from 'express';
import { verifySignature } from './middleware';
const app = express();
// Capture raw body for signature verification (BEFORE JSON parser)
app.use(express.raw({ type: '*/*', verify: (req, _res, buf) => {
(req as any).rawBody = buf;
} }));
app.use(verifySignature({
secrets: loadSecretsMap(),
}));
// Now safe to parse JSON
app.use(express.json());
app.get('/api/account', (req, res) => {
// req.verifiedCaller is the authenticated service name
res.json({ service: req.verifiedCaller });
});
HMAC request signing sits in a specific slot on the auth spectrum.
JWT requires a central issuer. Every service that wants to verify a token needs to fetch the signing key from the issuer, cache it, and handle rotation. If the issuer goes down, new services cannot start. This works well for user-facing sessions (the login flow bootstraps the token). It is overkill for a fixed set of internal services that should trust each other.
mTLS is cryptographically excellent and is the right choice if you already have a service mesh (Istio, Linkerd) managing the certificates for you. Without a mesh, you are managing per-service certificates, a CA, renewal cron jobs, and the operational complexity of rotating a CA when a key leaks. For a team that does not run a dedicated platform team, mTLS is a lot of ceremony.
API keys (a static string in a header) are the simplest option and also the least secure. They cannot prove that the request body was not tampered with. They are valid forever unless rotated. They leak in logs, in error messages, and in the occasional debug dump that gets pasted into GitHub issues. HMAC signing addresses all three weaknesses without adding a central dependency.
HMAC request signing is the pragmatic middle: no infrastructure dependencies, no certificate lifecycle, just a shared secret and a cryptographic proof that travels with every request.
HMAC works great inside a single trust domain (your VPC, your Kubernetes cluster). The moment you need to authenticate requests that cross organizational boundaries, or you need non-repudiation (proof that Service A sent a specific request, verifiable by a third party), switch to asymmetric signing with Ed25519.
// ed25519-sign.ts
import { createSign, createVerify } from 'node:crypto';
export function signEd25519(privateKey: Buffer, canonicalRequest: string): string {
const sign = createSign('ed25519');
sign.update(canonicalRequest);
return sign.sign(privateKey).toString('hex');
}
export function verifyEd25519(
publicKey: Buffer,
canonicalRequest: string,
signature: string
): boolean {
const verify = createVerify('ed25519');
verify.update(canonicalRequest);
return verify.verify(publicKey, signature, 'hex');
}
Ed25519 signatures are small (64 bytes), fast to compute, and the public key can be distributed freely without compromising the signing key. Use this when Service A should be able to prove to an auditor that “Service B really did send request X” without revealing Service B’s private key.
For everything else, HMAC-SHA256 with a 256-bit key is the right call.
Secrets should not live forever. Rotate them on a schedule (every 90 days is a common baseline). The trick is to support multiple valid keys during the rotation window:
function loadSecretsMap(): Map<string, Buffer> {
const primary = loadSecrets();
const map = new Map<string, Buffer>();
// Primary keys
for (const [svc, key] of Object.entries(primary)) {
map.set(svc, key);
}
// Rotating keys from a separate env var (e.g. ROTATING_SERVICE_SECRETS)
const rotatingRaw = process.env.ROTATING_SERVICE_SECRETS;
if (rotatingRaw) {
const rotating = JSON.parse(rotatingRaw);
for (const [svc, key] of Object.entries(rotating)) {
if (!map.has(svc)) {
map.set(svc, Buffer.from(key as string, 'hex'));
}
}
}
return map;
}
When rotating, deploy the new key into ROTATING_SERVICE_SECRETS on the receiver side first (making it accept both old and new signatures), then update the caller side to use the new key, then remove the old key from SERVICE_SECRETS. This gives you a clean cutover with no window where signatures fail.
A signing scheme is worthless if it breaks on the first edge case. Cover these scenarios:
// sign.test.ts
import { signRequest } from './sign';
import { createHmac } from 'node:crypto';
describe('signRequest', () => {
const secret = Buffer.from('00'.repeat(32), 'hex');
const baseOpts = {
secret,
method: 'POST',
path: '/api/account/123',
host: 'user-service.internal',
body: { userId: 123 },
};
it('produces a consistent signature for the same inputs', () => {
const a = signRequest(baseOpts);
const b = signRequest(baseOpts);
expect(a.signature).toBe(b.signature);
});
it('produces a different signature when the body changes', () => {
const a = signRequest(baseOpts);
const b = signRequest({ ...baseOpts, body: { userId: 456 } });
expect(a.signature).not.toBe(b.signature);
});
it('produces a different signature when the path changes', () => {
const a = signRequest(baseOpts);
const b = signRequest({ ...baseOpts, path: '/api/account/456' });
expect(a.signature).not.toBe(b.signature);
});
it('produces a different signature for a different HTTP method', () => {
const a = signRequest(baseOpts);
const b = signRequest({ ...baseOpts, method: 'DELETE' });
expect(a.signature).not.toBe(b.signature);
});
it('rejects requests with expired timestamps', () => {
const oldDate = new Date(Date.now() - 600_000); // 10 minutes ago
const result = signRequest({ ...baseOpts, date: oldDate });
// The receiver middleware with 5-min skew should reject this
const skew = 300_000;
const age = Date.now() - oldDate.getTime();
expect(age).toBeGreaterThan(skew);
});
});
The receiver tests should verify that tampered headers, replayed requests, and unknown credentials all return 401 before the request body is ever parsed.
This pattern has limits. Know them before you bet your production traffic on it.
No delegation. If Service A calls Service B on behalf of a user, Service B knows that Service A sent the request, but not which user. You need a separate user identity token (a JWT or session cookie) for that. The HMAC signature proves the caller’s machine identity, not the user’s.
Clock skew. Every service needs reasonably synchronized clocks (within 5 minutes is safe; within 60 seconds is better). A service whose clock drifts by 10 minutes will fail every request. Use NTP on every node.
Secret leaks. A compromised secret lets an attacker impersonate a service until the secret is rotated. Audit who has access to the secrets store. Rotate immediately after any suspected compromise.
Log leakage. The authorization header value is not secret (it is a signature, not a key), but the x-content-sha256 header reveals that two requests had identical bodies. If your threat model considers this a side channel, include a unique nonce in the canonical request string.
Sixty lines of signing logic. One middleware. One environment variable per service pair. No JWT issuer to maintain, no CA to stand up, no mTLS certificate rotation to babysit.
HMAC request signing is the pragmatic default for internal service-to-service auth. It solves three problems at once: it proves who sent the request, it proves the request was not tampered with, and the timestamp window prevents replay attacks. It is not the most sophisticated auth scheme in the world, but it is the one that most teams should reach for before they build a JWT infrastructure they do not need.
Add it in an afternoon. Test it in the morning. Ship it by lunch.
This post covers the kind of infrastructure decision that never makes a product demo but decides whether your platform survives its first real security review. Getting the details right (constant-time comparison, canonical request formatting, safe key rotation) is the difference between auth that works and auth that gives a false sense of safety.
That kind of production-grade systems engineering is exactly what Yojji ships. Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript stack (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and microservices architecture. They run dedicated senior outstaffed teams alongside full-cycle product engagements covering discovery, design, development, QA, and DevOps.
If your team would rather adopt proven internal-auth patterns than debug the consequences of skipping auth between services, Yojji is worth a conversation.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。