Skip to content

Signature verification (Ed25519)

Status Stable · Last verified 2026-07-12 · API v10 · Sources Interactions overview — Security & Authorization, discord-interactions-js

If you receive interactions over an HTTP endpoint, signature verification is mandatory — not a best practice you can defer. This page is the precise flow; the interaction-endpoint example implements it with zero dependencies and a passing local test.

Receiving interactions over the gateway instead? You don’t do this — the gateway connection is already authenticated by your bot token.

[!CAUTION] Verified — Discord enforces this Discord runs “automated, routine security checks” that include purposefully sending you invalid signatures. If your endpoint accepts one, Discord removes your interactions URL and alerts you via email and System DM. Source: Interactions overview.

Discord → your endpoint (POST) with headers:
X-Signature-Ed25519 : <hex signature>
X-Signature-Timestamp : <timestamp>
and a raw JSON body.
Verify:
message = X-Signature-Timestamp + <raw request body>
valid = Ed25519_verify(publicKey, signature, message)
if not valid → respond HTTP 401 and stop.
if valid → parse JSON; if type == 1 (PING) respond {"type": 1} (PONG).

Verified specifics from the docs:

  1. Headers. X-Signature-Ed25519 (the signature, hex) and X-Signature-Timestamp (the timestamp, unix seconds).
  2. What is signed. The timestamp concatenated with the request body, verified with Ed25519 against your app’s Public Key (Developer Portal → General Information).
  3. Return 401 when verification fails.
  4. Answer the PING (type: 1) with a PONG (type: 1).

Atlas hardening (recommended; implemented in the example):

  1. Timestamp skew / replay window. Even a correctly signed request should be rejected if |now − timestamp| is too large (the example uses 5 minutes). Signature alone does not stop an attacker from replaying a captured request hours later.
  2. Body size cap. Refuse oversized POSTs early (the example uses 256 KiB) so a bad client cannot force unbounded memory use before verification.

The one pitfall that causes most failures: the raw body

Section titled “The one pitfall that causes most failures: the raw body”

[!CAUTION] Verify the exact bytes Discord sent You must verify against the raw, unparsed body string — the bytes exactly as received. If a framework parses the JSON first and you re-serialize it (changing whitespace or key order), the signature will not match and every valid request fails. The official docs verify against the raw body string (request.data.decode("utf-8") in Python, req.rawBody in JS).

Practical consequences:

  • In Express, capture the raw body (e.g. express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })) or use the official verifyKeyMiddleware. Don’t verify JSON.stringify(req.body).
  • On serverless, read the raw request text before any automatic JSON parsing.

Reference implementation (zero-dependency, Node ≥ 20)

Section titled “Reference implementation (zero-dependency, Node ≥ 20)”

Node’s built-in WebCrypto verifies Ed25519 with a raw public key — no tweetnacl needed. This is the core of the atlas example:

import { webcrypto } from "node:crypto";
const { subtle } = webcrypto;
const MAX_SKEW_SEC = 5 * 60; // reject stale timestamps (replay window)
const hexToBytes = (hex) => {
if (typeof hex !== "string" || hex.length % 2 || !/^[0-9a-fA-F]*$/.test(hex)) {
throw new Error("invalid hex");
}
return Uint8Array.from(hex.match(/.{1,2}/g).map((b) => parseInt(b, 16)));
};
export async function verify(rawBody, signature, timestamp, publicKeyHex) {
if (!signature || !timestamp) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SEC) {
return false; // missing, non-numeric, or outside replay window
}
try {
const key = await subtle.importKey(
"raw", hexToBytes(publicKeyHex), { name: "Ed25519" }, false, ["verify"],
);
const message = new TextEncoder().encode(timestamp + rawBody);
return await subtle.verify(
{ name: "Ed25519" }, key, hexToBytes(signature), message,
);
} catch {
return false; // bad hex / bad key material — never fail open
}
}

Alternatively use Discord’s official discord-interactions (verifyKey / verifyKeyMiddleware), which many hosting guides reference.

The example generates its own Ed25519 keypair, signs a PING, and asserts the endpoint returns 200/PONG — then tampers the body and asserts 401:

Terminal window
cd examples/interaction-endpoint && node --test
# ✓ valid PING → 200 PONG
# ✓ tampered body → 401
# ✓ missing signature headers → 401
# ✓ valid /ping command → 200 message reply
# ✓ stale timestamp (outside replay window) → 401
# (+ unit tests for hex rejection and maxSkewSec)
  • Verify before parsing/altering the body.
  • Compare against timestamp + rawBody, Ed25519, your Public Key.
  • Return 401 on failure; never “fail open.”
  • Reject stale timestamps (replay window) and non-hex signatures.
  • Cap request body size before buffering unbounded input.
  • Answer PING (1) with PONG (1).
  • Keep the Public Key configured per environment (dev/prod apps differ).