m2m-keygen
v2.0.0
Published
Simplify Machine to Machine signature generation in a secure way
Readme
M2mKeygen
This library exists to simplify Machine to Machine signature generation and
verification in a secure way. It is the TypeScript twin of the
m2m_keygen Ruby gem: both
implementations produce and verify byte-for-byte identical signatures under
the m2m-keygen/2 scheme, and are deployed in lockstep — whichever side you
run, the other end can be either language.
If you are coming from a
1.xversion: the signature scheme changed in2.0.0and is not compatible anymore. See docs/MIGRATING.md.
Installation
Add the package to your project by executing:
$ pnpm add m2m-keygenor with npm
$ npm install --save m2m-keygenRequires Node >= 22. Zero runtime dependencies.
Usage
The 2 servers share the same secret key. The sender signs the request it is about to send and puts the signature (with an expiry and a nonce) in headers. The receiver generates the same signature from the request it received and compares them.
Signing a request (client)
Use createRequestSigner to build a signer once, then call signRequest for
every outgoing request. It builds the query string to send and the headers to
add, generating a nonce and an expiry for you.
verbis the HTTP verb.pathis the request path.paramsis a flat params object (no nested objects/arrays of arrays). Key order doesn't matter, the lib reformats it.bodyis optional — the raw bytes you will send.
import { createRequestSigner } from 'm2m-keygen';
const signer = createRequestSigner({ secret: 'my_secret_key' }); // algorithm defaults to sha512
const signed = signer.signRequest({
verb: 'GET',
path: '/orders',
params: { since: '2026-01-01', limit: 50 },
});
signed.query; // => the query string to send
signed.headers; // => { 'X-Signature': ..., 'X-M2M-Expiry': ..., 'X-M2M-Nonce': ... }After signing, send signed.query and signed.headers alongside your
request. With the native fetch:
const url = `https://api.example.com/orders${signed.query ? `?${signed.query}` : ''}`;
const response = await fetch(url, {
headers: signed.headers,
});The body is opaque: this library never serializes it for you. For JSON,
stringify it yourself and set Content-Type:
const body = JSON.stringify(payload);
const signed = signer.signRequest({ verb: 'POST', path: '/orders', body });
await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { ...signed.headers, 'Content-Type': 'application/json' },
body,
});generateFetcher is a shortcut that wraps this recipe around any
fetch-compatible function:
import { generateFetcher } from 'm2m-keygen';
const signedFetch = generateFetcher({
fetcher: fetch,
secret: 'my_secret_key',
});
await signedFetch('https://api.example.com/orders', { since: '2026-01-01' });Validating a request (server)
Use createValidator to build a validator once (e.g. at startup), then call
validateRequest for every incoming request. It checks:
- Signature matching.
- That
expiry(in theX-M2M-Expiryheader) is present and within the acceptance window. - That
nonce(in theX-M2M-Nonceheader) has never been seen before, so the request can't be replayed.
import { createValidator, MemoryNonceStore } from 'm2m-keygen';
const validator = createValidator({
secret: 'my_secret_key',
nonceStore: new MemoryNonceStore(), // required, see below
});Example with Express — pass the raw path/query straight from req.url (never
re-parsed through URL/URLSearchParams) and the raw body bytes:
app.post('/orders', express.raw({ type: '*/*' }), async (req, res) => {
// Split on the FIRST '?' only — a literal '?' is legal inside a query.
const sep = req.url.indexOf('?');
const path = sep === -1 ? req.url : req.url.slice(0, sep);
const query = sep === -1 ? '' : req.url.slice(sep + 1);
const isValid = await validator.validateRequest({
verb: req.method,
path,
query,
body: req.body, // a Buffer, thanks to express.raw()
headers: req.headers,
});
if (!isValid) return res.sendStatus(401);
// ...
});If you're on a runtime that hands you a WHATWG Request (Cloudflare
Workers, Deno, undici...), read path/query from new URL(request.url)
and body from await request.arrayBuffer() — just make sure you're not
re-deriving path/query from a value that was already re-normalized
somewhere upstream (see the warning below).
The 3 signature headers
| Header | Contents |
| -------------- | ------------------------------------------------- |
| X-Signature | The HMAC hex digest. |
| X-M2M-Expiry | Unix timestamp in seconds, as a decimal string. |
| X-M2M-Nonce | A random token, used once, for replay protection. |
[!WARNING] The signed
queryandpathmust reach the server verbatim — the exact bytes that were signed. Never reconstruct the URL throughURLorURLSearchParamsafter signing (or before validating): re-encoding, re-ordering, or re-normalizing even a single character changes the bytes the signature covers and breaks validation, even though the logical request is unchanged.
Choosing a nonce store
The nonce is what really stops replay: a captured request can't be replayed because its nonce is remembered until it expires. You have to choose a store explicitly — there is no default, so you don't end up without replay protection without knowing it.
MemoryNonceStorefor a single-process app or local development. Careful: with several workers or several hosts, each process has its own memory, so the replay protection is only partial. Use a shared store in production.- A shared store (Redis, Postgres, ...) for production. Reference
implementations — not part of the published package, copy and adapt them —
live under
examples/nonceStore/:redis.tsusesSET ... NX PXfor an atomic check-and-set with a native TTL;postgres.tsusesINSERT ... ON CONFLICT DO NOTHING RETURNINGplus apurgeExpired()helper to call from a cron. DisabledNonceStoreif you explicitly don't want replay protection (expiry-only).
Configuration options
Both createRequestSigner and createValidator accept:
algorithm— an HMAC digest name Node supports, default'sha512'.headerName/expiryHeader/nonceHeader— override the 3 header names, default'X-Signature'/'X-M2M-Expiry'/'X-M2M-Nonce'.
createRequestSigner additionally accepts a per-call expiry/nonce
override; the default expiry is now + 90s (DEFAULT_EXPIRY_TTL_SECONDS).
createValidator additionally accepts window — the acceptance window in
seconds around now, strictly now < expiry < now + window, default 120.
Low-level: sign / validate
If you need to sign or validate a request without going through headers,
sign/validate are the low-level primitives both helpers are built on:
import { sign, validate } from 'm2m-keygen';
const signature = sign({
secret: 'my_secret_key',
verb: 'get',
path: '/orders',
expiry: 1_700_000_000,
nonce: 'a-nonce',
query: 'a=1',
});
validate({
secret: 'my_secret_key',
signature,
verb: 'get',
path: '/orders',
expiry: 1_700_000_000,
nonce: 'a-nonce',
query: 'a=1',
}); // => trueHow does it work
This is intended for a secure discussion between 2 servers and not something in a browser as the secret key must be stored and used on both sides (and you don't want to send the secret key in the browser).
Both servers have the same secret key. The sender generates a signature matching the HTTP request it is about to send (the verb, the path, the query and the body, with an expiry and a nonce) and adds it to the request in the headers above. The receiver generates the same signature from the request it received and compares it with the received signature, in constant time.
The exact byte format — the canonical string, the length-prefixed encoding, the HMAC — is described in the Ruby gem's docs/SPEC.md, with golden vectors. That document is the cross-language contract this library reproduces exactly.
Cross-language caveat: String(1.0) === '1' in JavaScript, whereas
Ruby's 1.0.to_s === '1.0'. A number param value that must sign
identically on both ends should be sent as a string or bigint instead —
those round-trip byte-for-byte across languages.
Development
After checking out the repo, run pnpm install to install dependencies.
Then, run pnpm test to run the tests.
pnpm test— run the unit/integration test suite (Vitest).pnpm build— build the dual ESM/CJS package intodist/.pnpm typecheck/pnpm lint/pnpm coverage— the other CI gates.
To run the cross-language E2E suite against a live Ruby server locally:
cd tests/e2e/ruby && bundle install && bundle exec rackup -p 9292
M2M_RUBY_URL=http://localhost:9292 pnpm vitest run tests/e2e/ts-to-ruby.e2e.test.tsEvery commit/push is checked by husky.
Tools used in dev:
- ESLint
- Prettier
- TypeScript
- Vitest
Migration
Upgrading from 1.x? See docs/MIGRATING.md.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/zaratan/m2m_keygen_ts. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
This library is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the M2mKeygen project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.
