Describe the bug
With self-hosted inngest/inngest:v1.22.0 and SDK inngest@4.4.0, every step execution from the server fails signature validation with Unauthorized / Invalid signature.
The server sends step-execution requests as GET with a JSON body and a signed x-inngest-signature header computed over body + ts. The SDK, however, only reads the body for signature verification when method === "POST" || method === "PUT" — for GET it signs "" + ts, so the signatures never match.
The signature algorithm itself is correct on both sides; the disagreement is only about whether the body is included in the input.
Reproduction (offline)
import { createHmac } from "node:crypto";
const KEY = "<INNGEST_SIGNING_KEY>"; // shared with server
const TS = "1779525304";
const EXPECTED = "c4afd10126bc710c35d94bd005b2028761d5771d2a52356545ad720f393256f3"; // s= from server
const BODY = '{"ctx":{...},"event":{...},"events":[...],"steps":{},"version":-1}'; // 792 bytes
// What the server signed:
createHmac("sha256", Buffer.from(KEY, "utf8")).update(BODY + TS).digest("hex");
// → c4afd10126bc710c35d94bd005b2028761d5771d2a52356545ad720f393256f3 ✓ MATCH
// What the SDK computes (because method === "GET"):
createHmac("sha256", Buffer.from(KEY, "utf8")).update("" + TS).digest("hex");
// → d37d168b4bf0ed2b165f69fe6215cef865b728037786a0a3a2601d9402383288 ✗
SDK code path
InngestCommHandler.handleAsyncRequest (v4.4.0, identical in v3.x):
methodP.then(async (method) => {
if (method === "POST" || method === "PUT") {
const body = await actions.body(...);
if (typeof body === "string") return JSON.parse(body);
return body;
}
return ""; // ← GET signs empty body
});
And handleAction only executes user functions for method === "POST" || forceExecution. For GET it always returns introspection, so even if the signature check were bypassed the step would never actually run.
Inbound request from the server
- method:
GET
- url:
/api/inngest?fnId=<fn>&stepId=step
- headers:
x-inngest-signature, x-inngest-run-id, x-inngest-job-id, x-inngest-step-id, x-inngest-req-version: -1
- content-type:
application/json
- body: ~800 bytes JSON (
{"ctx":{...},"event":{...},"events":[...],"steps":{},"version":-1})
These are not unsigned probes (issue #1539). They carry a valid x-inngest-signature computed over the body — isInngestReq() would return true for them (both x-inngest-run-id and x-inngest-signature are present).
Expected behaviour
Either:
- SDK side: when
isInngestReq() is true (both run-id and signature headers present), read the body and validate signature regardless of HTTP method, and proceed to step execution. OR
- Server side: send step-execution as
POST, never GET with body.
The current behaviour traps users on self-hosted into a deadlock:
- SDK
4.4.0 → step execution returns 401, cron/function never runs.
- SDK
<= 4.2.5 → step execution returns 200 with introspection body (silently bypasses signature check for GET), server logs the run as successful, but the user function never executes. Worse: silent.
Workaround (current)
Promote req.method to "POST" in the framework adapter when the inbound request is a GET with a non-empty string body:
router.all("/api/inngest", (req, res, next) => {
if (typeof req.rawBody === "string") req.body = req.rawBody;
if (req.method === "GET" && typeof req.body === "string" && req.body.length > 0) {
Object.defineProperty(req, "method", { value: "POST", configurable: true, writable: true });
}
return inngestHandler(req, res, next);
});
Signature validation then succeeds and the step runs normally. The query string (fnId/stepId) is already in step-execution shape, so no other transformation is needed.
Versions
inngest (SDK): 4.4.0
inngest/inngest (server, self-hosted Docker): v1.22.0
- Node:
22
- Framework adapter:
inngest/express (Express 5)
Related
Describe the bug
With self-hosted
inngest/inngest:v1.22.0and SDKinngest@4.4.0, every step execution from the server fails signature validation withUnauthorized/Invalid signature.The server sends step-execution requests as
GETwith a JSON body and a signedx-inngest-signatureheader computed overbody + ts. The SDK, however, only reads the body for signature verification whenmethod === "POST" || method === "PUT"— forGETit signs"" + ts, so the signatures never match.The signature algorithm itself is correct on both sides; the disagreement is only about whether the body is included in the input.
Reproduction (offline)
SDK code path
InngestCommHandler.handleAsyncRequest(v4.4.0, identical in v3.x):And
handleActiononly executes user functions formethod === "POST" || forceExecution. ForGETit always returns introspection, so even if the signature check were bypassed the step would never actually run.Inbound request from the server
GET/api/inngest?fnId=<fn>&stepId=stepx-inngest-signature,x-inngest-run-id,x-inngest-job-id,x-inngest-step-id,x-inngest-req-version: -1application/json{"ctx":{...},"event":{...},"events":[...],"steps":{},"version":-1})These are not unsigned probes (issue #1539). They carry a valid
x-inngest-signaturecomputed over the body —isInngestReq()would returntruefor them (bothx-inngest-run-idandx-inngest-signatureare present).Expected behaviour
Either:
isInngestReq()is true (both run-id and signature headers present), read the body and validate signature regardless of HTTP method, and proceed to step execution. ORPOST, neverGETwith body.The current behaviour traps users on self-hosted into a deadlock:
4.4.0→ step execution returns 401, cron/function never runs.<= 4.2.5→ step execution returns 200 with introspection body (silently bypasses signature check for GET), server logs the run as successful, but the user function never executes. Worse: silent.Workaround (current)
Promote
req.methodto"POST"in the framework adapter when the inbound request is a GET with a non-empty string body:Signature validation then succeeds and the step runs normally. The query string (
fnId/stepId) is already in step-execution shape, so no other transformation is needed.Versions
inngest(SDK):4.4.0inngest/inngest(server, self-hosted Docker):v1.22.022inngest/express(Express 5)Related