DEV Community

SEN LLC
SEN LLC

Posted on

Discord Slash Commands Without an SDK: ed25519 in 250 Lines of TypeScript

Discord Slash Commands Without an SDK: ed25519 in 250 Lines of TypeScript

A minimal Hono reference for Discord interaction webhooks. Verifies ed25519 signatures using only Node's built-in crypto module. Three runtime deps total. The interesting bit is a 12-byte DER prefix.

📦 GitHub: https://github.com/sen-ltd/discord-slash-demo

Screenshot

If you've ever set up a Discord bot the "new" way — slash commands, interaction webhooks, no gateway connection — you've probably hit this wall: the Developer Portal asks you for an Interactions Endpoint URL, you paste one in, and it immediately answers with a cheerful "URL could not be verified." No logs. No explanation. Just no.

What Discord is doing behind the scenes is sending your endpoint a signed POST request of type PING, waiting for you to answer with a PONG, and silently failing if either the signature check or the response shape is wrong. The signature check is where most people get stuck, and the tutorials split cleanly into two camps: one that tells you to npm install discord.js and stop asking questions, and one that reaches for tweetnacl because Node "doesn't have ed25519." Neither is what I wanted — discord.js is 2 MB of framework I'm not going to use, and the claim about Node is flatly wrong. Node 20 has had crypto.verify(null, ...) for ed25519 since 2021.

So I wrote the small reference I wished existed when I first tried this: a Hono service that correctly verifies Discord's signature, dispatches three example commands, and fits in 250 lines. Zero SDKs. Three runtime deps: hono, @hono/node-server, and zod (for command argument validation). This article walks through the three bits that weren't obvious the first time through.

The problem statement, precisely

The Discord interactions doc says: "For security, Discord signs every request with your application's public key using ed25519. You must validate the signature, otherwise you must respond with 401 Unauthorized."

Concretely, each POST to your /interactions endpoint comes with:

  • X-Signature-Ed25519: a 128-char hex string (64 raw bytes)
  • X-Signature-Timestamp: an opaque string, treat it as a bytestring
  • A JSON request body

You verify by hashing timestamp || raw_body and checking the ed25519 signature against your application's public key. The public key is displayed in the Developer Portal as a 64-char hex string — 32 raw bytes, one full ed25519 point. Reply 401 on any mismatch; reply 200 with {type: 1} on a valid PING; dispatch valid APPLICATION_COMMAND interactions to your handler.

Simple contract. Four places to go wrong:

  1. You use c.req.json() first. Hono's body parser consumes the raw bytes, and re-serializing the parsed object does not round-trip (key ordering, whitespace, numeric precision). You must grab the raw bytes before anything else touches the body.
  2. You load the public key wrong. createPublicKey doesn't accept a raw 32-byte ed25519 point. It wants PEM, DER, JWK, or a pem-like object. Pasting the hex string in is going to throw.
  3. You forget to concat the timestamp. verify(publicKey, body, sig) will not match anything. The message is timestamp + body, not body.
  4. You return 403 instead of 401. Discord specifically wants 401 for signature failures. It will retry a few times then mark your endpoint unhealthy.

Let's walk through how to avoid all four.

Design: the three interesting files

The repo has five source files and three test files. The interesting ones are:

  • src/verify.ts — hexPublicKeyToKeyObject() and verifySignature(). Pure. No globals. Injectable into any framework.
  • src/app.ts — createApp({publicKey, commands}) factory. Hono route for /interactions. No globals either.
  • src/commands/index.ts — a tiny CommandRegistry with register() and dispatch(). Each command is a {name, handle} pair.

The factory pattern is deliberate. Tests generate a real ed25519 keypair with generateKeyPairSync('ed25519'), derive the public key as hex, wrap it with the same function production uses, and inject the result into createApp(). No mocks. No globals that need resetting between tests. The Hono app.request() helper then runs the whole HTTP stack in-process, which means the tests actually exercise the signature verification code path — not a stub.

The SPKI wrapping trick

Here's the file you come for:

import { createPublicKey, verify, type KeyObject } from 'node:crypto';

const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');

export function hexPublicKeyToKeyObject(hex: string): KeyObject {
  const raw = Buffer.from(hex, 'hex');
  if (raw.length !== 32) {
    throw new Error(`public key must be 32 bytes, got ${raw.length}`);
  }
  const der = Buffer.concat([ED25519_SPKI_PREFIX, raw]);
  return createPublicKey({ key: der, format: 'der', type: 'spki' });
}
Enter fullscreen mode Exit fullscreen mode

What is that magic 12-byte prefix? It's a DER-encoded SubjectPublicKeyInfo header, hand-assembled:

0x30 0x2a           SEQUENCE, length 42
  0x30 0x05         SEQUENCE, length 5       — AlgorithmIdentifier
    0x06 0x03 0x2b 0x65 0x70   OID 1.3.101.112 (id-Ed25519)
  0x03 0x21         BIT STRING, length 33
    0x00            "unused bits" byte
    <32 bytes>      raw ed25519 public key
Enter fullscreen mode Exit fullscreen mode

ed25519 public keys are fixed-size (32 bytes) and there are no algorithm parameters to encode, so the SPKI header is a constant. Every Discord app key fits this exact shape; no per-key computation needed. You concat the 12 bytes of header with the 32 bytes of key, feed it to createPublicKey({format: 'der', type: 'spki'}), and out comes a KeyObject that works with verify(null, ...).

I verified this against createPublicKey applied to the PEM form of the same key — they produce identical JWK exports. One of the tests asserts exactly that, because if the SPKI prefix is ever wrong, everything else in the repo is wrong too:

it('builds the same KeyObject as createPublicKey on PEM', () => {
  const { publicKey } = generateKeyPairSync('ed25519');
  const pem = publicKey.export({ format: 'pem', type: 'spki' }) as string;
  const fromPem = createPublicKey({ key: pem, format: 'pem' });
  const hex = publicKeyToHex(publicKey);
  const fromHex = hexPublicKeyToKeyObject(hex);
  const a = (fromPem.export({ format: 'jwk' }) as { x?: string }).x;
  const b = (fromHex.export({ format: 'jwk' }) as { x?: string }).x;
  expect(a).toBe(b);
});
Enter fullscreen mode Exit fullscreen mode

If you remember one thing from this article: Discord's public key is already a valid ed25519 point. You are not doing cryptography here. You are doing ASN.1 plumbing. The 12 bytes above are the plumbing.

Verification, and why the raw body matters

The verification function itself is three lines of real work:

export function verifySignature(
  publicKey: KeyObject,
  signatureHex: string,
  timestamp: string,
  rawBody: string,
): boolean {
  const sig = Buffer.from(signatureHex, 'hex');
  if (sig.length !== 64) return false;
  const message = Buffer.concat([
    Buffer.from(timestamp, 'utf8'),
    Buffer.from(rawBody, 'utf8'),
  ]);
  try {
    return verify(null, message, publicKey, sig);
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Note verify(null, ...). For ed25519, Node expects null as the hash algorithm, because ed25519 has its own internal hashing and there's nothing to pick. Pass anything else and you'll get Error: digest is not allowed for this key type.

The rawBody parameter is the load-bearing part of the function signature. In Hono, the temptation is to write:

// WRONG — do not do this
const body = await c.req.json();
if (!verifySignature(pk, sig, ts, JSON.stringify(body))) return c.json(..., 401);
Enter fullscreen mode Exit fullscreen mode

This will fail verification about 60% of the time on real Discord traffic. Reason: the rawBody string that Discord signed is a specific sequence of bytes — whatever exact stringification their Go services produced. JSON.parse followed by JSON.stringify in Node will sometimes produce bytes that differ in key ordering, numeric formatting (1.0 vs 1), or whitespace. Since ed25519 is a cryptographic hash over the literal bytes, even one byte of difference turns a valid signature into an invalid one.

So the route reads the body once, as raw bytes, and holds on to both the bytes and (after verification) the parsed value:

app.post('/interactions', async (c) => {
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');

  if (!signature || !timestamp) {
    return c.json({ error: 'missing_signature_headers' }, 401);
  }

  // Raw bytes BEFORE any JSON parser touches them.
  const rawBody = await c.req.raw.text();

  if (!verifySignature(deps.publicKey, signature, timestamp, rawBody)) {
    return c.json({ error: 'invalid_signature' }, 401);
  }

  let interaction;
  try {
    interaction = JSON.parse(rawBody);
  } catch {
    return c.json({ error: 'malformed_body' }, 400);
  }

  if (interaction.type === 1) return c.json({ type: 1 });       // PONG
  if (interaction.type === 2) return c.json(commands.dispatch(interaction));
  return c.json({ error: 'unsupported_interaction_type' }, 400);
});
Enter fullscreen mode Exit fullscreen mode

c.req.raw is the underlying standard-library Request object. .text() returns the body as a UTF-8 string, without touching it. That's the bytes we verify, and we re-parse the same string for the dispatcher. No round-trip, no drift.

The command dispatcher

The third interesting piece is the command registry. It's a dozen lines and looks like every other dispatcher you've ever written:

export interface CommandHandler {
  name: string;
  handle: (interaction: Interaction) => InteractionResponse;
}

export class CommandRegistry {
  private readonly handlers = new Map<string, CommandHandler>();

  register(h: CommandHandler): void { this.handlers.set(h.name, h); }
  names(): string[] { return [...this.handlers.keys()].sort(); }

  dispatch(interaction: Interaction): InteractionResponse {
    const name = interaction.data?.name;
    const handler = name ? this.handlers.get(name) : undefined;
    if (!handler) {
      return { type: 4, data: { content: `Unknown command: \`${name}\`` } };
    }
    return handler.handle(interaction);
  }
}
Enter fullscreen mode Exit fullscreen mode

Each command is a small file. Here's /echo, which needs argument validation because Discord's option shape is loose:

const EchoOptions = z.array(
  z.object({
    name: z.string(),
    value: z.union([z.string(), z.number(), z.boolean()]),
  }),
);

export const echoCommand: CommandHandler = {
  name: 'echo',
  handle: (interaction) => {
    const parsed = EchoOptions.safeParse(interaction.data?.options ?? []);
    if (!parsed.success) {
      return { type: 4, data: { content: 'echo: invalid options' } };
    }
    const message = parsed.data.find((o) => o.name === 'message');
    if (!message || typeof message.value !== 'string') {
      return { type: 4, data: { content: 'echo: missing `message` string' } };
    }
    return { type: 4, data: { content: message.value.slice(0, 1900) } };
  },
};
Enter fullscreen mode Exit fullscreen mode

The 1900-char cap is a safety buffer below Discord's 2000-char message limit. If a user passes something longer, you'll get a 400 from Discord's API when your response is dispatched — which is worse than truncating, because your interaction then shows up as a failed reply.

/roll is the same pattern plus an injectable randomBelow dependency so tests can seed it:

export function createRollCommand(deps: RollDeps = defaultDeps): CommandHandler {
  return {
    name: 'roll',
    handle: (interaction) => {
      const sidesOpt = (interaction.data?.options as any[])
        ?.find((o) => o.name === 'sides');
      const sides = sidesOpt?.value ?? 6;
      if (!Number.isInteger(sides) || sides < 2 || sides > 1_000_000) {
        return { type: 4, data: { content: 'roll: sides must be 2..1000000' } };
      }
      const result = deps.randomBelow(sides) + 1;
      return { type: 4, data: { content: `You rolled a **${result}** (d${sides})` } };
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Default is randomInt from node:crypto. Test override is () => 5. No mocking libraries, no vi.mock, just a plain function argument.

The tests exercise real crypto

Most of the article's value is in tests/http.test.ts. It doesn't stub signature verification — it does signature verification, inside the test, with a real keypair, against the real route:

const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const hex = publicKeyToHex(publicKey);
const app = createApp({
  publicKey: hexPublicKeyToKeyObject(hex),
  commands: new CommandRegistry([pingCommand, echoCommand, rollCommand]),
});

const ts = '1700000001';
const body = JSON.stringify({ type: 1 });
const msg = Buffer.concat([Buffer.from(ts), Buffer.from(body)]);
const sig = sign(null, msg, privateKey).toString('hex');

const res = await app.request('/interactions', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-signature-ed25519': sig,
    'x-signature-timestamp': ts,
  },
  body,
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ type: 1 });
Enter fullscreen mode Exit fullscreen mode

That one test covers the PING handshake Discord performs during URL registration — which is the exact thing that fails when you set up a new bot. If this test passes, your endpoint will pass Discord's URL verification. The remaining 14 HTTP tests cover: each command happy path, signature mismatch (tampered body), foreign-keypair signature, missing headers, malformed JSON, missing type, unknown type. Total: 42 tests across verify / commands / http / middleware.

Tradeoffs and deliberate non-features

This repo is a reference, not a framework. That means:

  • No command registration helper. Discord commands are registered with a separate HTTP call (POST /applications/{id}/commands), not through the interactions webhook. I show the curl command in the README. If you want a declarative registration file with diffing, write one — it's ten lines of fetch.
  • No ephemeral / deferred responses beyond the basic {type: 4} flag. Discord supports {type: 5} (deferred channel message) for handlers that need more than 3 seconds, and a flags: 64 field for ephemeral replies. Both are additive; neither requires changes to verification.
  • No component handlers. Buttons and select menus deliver type: 3 (MESSAGE_COMPONENT) and type: 5 (MODAL_SUBMIT) interactions. This repo only dispatches type: 2 (APPLICATION_COMMAND). The dispatcher is easy to extend.
  • No followup messages. Sending a second message after the initial response uses PATCH /webhooks/{app_id}/{interaction_token}/messages/@original, which is an outbound HTTP call — unrelated to webhook verification. Out of scope here.
  • Not discord.js. If you want presence, voice, or a command framework, use discord.js. If you want a signed-webhook reference you can read in one sitting, use this.

Everything above is intentional. The goal is to show you the minimum viable interactions endpoint, not to reimplement a framework.

Try it in 30 seconds

The Dockerfile is multi-stage node:20-alpine, non-root, 146 MB. You can run the container locally with a fake key — verification will fail on real Discord traffic, but the health check and the 401-on-missing-headers path both work, and the in-container test suite exercises the full crypto path with a test-generated real keypair:

git clone https://github.com/sen-ltd/discord-slash-demo
cd discord-slash-demo
docker build -t discord-slash-demo .
docker run --rm -d -p 8000:8000 --name dsd \
  -e DISCORD_PUBLIC_KEY=0000000000000000000000000000000000000000000000000000000000000000 \
  discord-slash-demo

curl -s http://localhost:8000/health | jq
# {"status":"ok","version":"0.1.0","commands":["echo","ping","roll"]}

curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8000/interactions \
  -H "Content-Type: application/json" -d '{"type":1}'
# 401

docker stop dsd
Enter fullscreen mode Exit fullscreen mode

Want to exercise the real verification flow without Discord? Build the test target and run vitest inside the container. The tests generate their own keypair, sign requests, and POST them through the full Hono stack:

docker build --target builder -t dsd-test .
docker run --rm --entrypoint sh dsd-test -c "cd /build && npm test"
# Test Files  4 passed (4)
#      Tests  42 passed (42)
Enter fullscreen mode Exit fullscreen mode

To actually hook this up to Discord: create an application on the Developer Portal, copy the Public Key into DISCORD_PUBLIC_KEY, deploy the container somewhere with HTTPS (Fly.io, Railway, a small VPS with Caddy), and paste the https://your-host/interactions URL into the Developer Portal's Interactions Endpoint URL field. Discord sends a PING, your endpoint answers with a PONG, the Portal says Verified and saves. Total elapsed time from a fresh Discord app to a working /ping in Discord chat: about five minutes, almost all of it on Discord's side.

The point of the exercise, again: no SDK, no tweetnacl, no frameworks you don't understand. Three deps, one node:crypto trick, and a dispatcher. That's the whole reference.

Read the full source at https://github.com/sen-ltd/discord-slash-demo — the src/verify.ts file is 90 lines, most of which are comments.

Top comments (0)