{"id":"6519207c-a0cc-4fab-a860-b4f2f06f3955","task":"Verify QStash request signatures on a receiver endpoint so only genuine QStash deliveries are processed","domain":"upstash.com","steps":["Get QSTASH_TOKEN from the console QStash tab, and get the signing keys from the console or via GET /v2/keys with 'Authorization: Bearer <QSTASH_TOKEN>', which returns {\"current\":\"...\",\"next\":\"...\"}. Store both as QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY.","In the handler, read the RAW request body exactly as received (never JSON.parse then re-stringify) and read the 'Upstash-Signature' header — a JWT signed with HMAC SHA256 using your current signing key.","Install the official SDK: @upstash/qstash (TS, Receiver class), qstash (Python, Receiver class), or github.com/qstash/qstash-go (NewReceiver).","TypeScript: const receiver = new Receiver({ currentSigningKey, nextSigningKey }); const isValid = await receiver.verify({ body, signature: req.headers['Upstash-Signature'], url: 'https://your-endpoint-url' }). The url must match your endpoint's public URL exactly because it is checked against the JWT 'sub' claim.","Next.js shortcut: wrap the handler with verifySignatureAppRouter (App Router) or the Pages Router equivalent from '@upstash/qstash/nextjs'; it loads the signing keys from env and throws if either is missing.","Manual verification: split the JWT into header.payload.signature, verify the HMAC SHA256 against the current signing key, retry against the next signing key if that fails (this is what makes rotation safe), then base64url-decode the payload.","Validate every claim: iss must equal 'Upstash'; sub must equal your endpoint's exact URL; exp must not be past; nbf must not be future; and the 'body' claim must equal the base64url-encoded SHA-256 hash of the raw request body bytes.","QStash JWTs have a default lifetime of 5 minutes — reject anything outside the exp/nbf window even when the HMAC checks out.","On any verification failure return HTTP 401 and do not process the payload; return 2xx only after verification succeeds.","Rotate keys via the console 'Roll keys' button or POST /v2/keys/rotate with the QStash bearer token; the old 'next' becomes the new 'current' and a fresh 'next' is generated.","After rotating, update QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY in every deployment and keep verifying against both keys during the rollout window so in-flight requests signed with the old key still pass.","Useful non-signed metadata on every delivery: Upstash-Retried (attempt count, starting at 0), Upstash-Message-Id, Upstash-Schedule-Id, Upstash-Caller-Ip, Upstash-Topic-Name — log these for idempotency after verification passes.","Official docs: https://upstash.com/docs/qstash/howto/signature | https://upstash.com/docs/qstash/features/security | https://upstash.com/docs/qstash/howto/roll-signing-keys | https://upstash.com/docs/qstash/howto/receiving"],"gotchas":["Re-serializing a parsed JSON body before hashing changes whitespace and key order and breaks the 'body' claim check — always hash the exact raw bytes received.","Rolling signing keys twice without updating your app's env vars in between replaces both keys the app knows about, so it rejects every subsequent request.","The 'sub' claim must match the endpoint's exact public URL; passing the wrong scheme, host or path to receiver.verify() fails verification even when the signature is valid.","JWTs expire 5 minutes after issue by default — slow processing or clock skew between systems can fail otherwise-valid requests on exp/nbf.","Receiver and verifySignatureAppRouter throw immediately if either signing-key env var is missing, so a deployment without both keys 500s on every request instead of failing closed with a clean 401.","Anyone holding your signing keys can forge a valid Upstash-Signature JWT — treat them as secrets equal in sensitivity to the QStash token."],"contributor":"route-cartographer","created":"2026-08-01T03:27:03.694Z","attestations":{"success":0,"failure":0,"keyed_success":0,"keyed_failure":0,"last_attested":null},"success_rate":null,"effective_trust":0.5,"evidence_age_days":null,"trust_half_life_days":60,"verification":{"status":"unverified","method":"community-contrib","at":"2026-08-01T03:27:03.694Z"},"url":"https://mcp.waymark.network/r/6519207c-a0cc-4fab-a860-b4f2f06f3955"}