The guild ID that never meant anything

Last updated July 20, 2026

← Back to blog

A lot of SlashBot commands take a message link. You paste a URL, the bot finds that message, and it edits an embed or swaps out some buttons. The links look like this:

https://discord.com/channels/<server>/<channel>/<message>

Three IDs, left to right, from broadest to narrowest. It reads like a path. Server contains channel, channel contains message. So the obvious way to stop someone pointing our bot at a message in a server they have nothing to do with is to check that first segment:

const link = parseMessageLink(input)
if (!link) return error('INVALID_LINK')

if (link.serverId !== interaction.serverId) {
  return error('EXTERNAL_LINK')
}

const message = await bot.fetchMessage(link.channelId, link.messageId)
// ... edit the message

That check ran on every command that accepts a link. It looks right. It is not.

Three IDs that were never related

The problem is on the last line. Nothing ever resolves the server segment. Look at what the fetch actually consumes: a channel ID and a message ID. That is the entire input. The server ID was compared against a value and then dropped on the floor.

Discord will happily serve you a channel by ID alone, and a bot token is authorised in every server that bot is in. There is no per-server scoping on that request. So the three segments in the URL are not a hierarchy at all, they are three independent lookups that a human reads as a path.

Which means you can write a link where the first segment is your server and the other two point somewhere else entirely:

https://discord.com/channels/<your server>/<their channel>/<their message>

The equality check passes, because you genuinely are in the server you named. Then the fetch ignores that entirely and goes and gets somebody else's message. The guard was comparing a user-supplied string against itself and calling it authorisation.

This is the part worth sitting with: the check was not missing. It was present, it was deliberate, and it had a sensible error message attached to it. It just did not do anything. That is a much harder thing to spot in review than a check that is absent.

What that actually got you

Enough to matter, less than it first appears. Messages written by people were never editable, since Discord does not let a bot edit a message it did not write. Reads are the case that needed a guard, because a bot can fetch anything in a channel it can see:

if (message.author.id !== bot.applicationId) {
  return error('NOT_SENT_BY_CLIENT')
}

So the exposure, both directions, was limited to messages the bot had posted: rules embeds, announcements, role menus. You could rewrite their contents, strip their components, or read them back out of channels you had no access to.

The sharpest version of this is not data theft, it is impersonation. Silently editing an announcement in a server you are not in, so that it still carries the bot's name and avatar but now says whatever you want, is a genuinely nasty phishing primitive.

What it did not allow was privilege escalation, and the reason is worth stating because it is the one piece of validation that held. Role IDs are scoped to a single server. A role menu rebuilt from another server references roles that do not exist in the target, so there is no path from this to handing yourself a role. And if you were already a member of the target server, you could simply have clicked the real menu. Tokens, server settings and anything living outside a message were never in reach either.

The autocomplete made it quieter

One detail that made this worse than the command surface suggests. Several of these commands have autocomplete on a follow-up option, which means the bot fetches the target message while you are still typing, to populate the choices.

Those handlers carried the same broken check. So the information disclosure did not require running a command at all. You could type a crafted link, read embed titles and button labels out of the suggestion list, and never submit anything. No command invocation, nothing for a server to notice.

The fix

The correct invariant is not “the link says the right server”. It is the channel genuinely belongs to the server you are in. So resolve the channel and ask it, rather than trusting a string:

async function channelBelongsToServer(channelId, serverId) {
  const channel = await bot.fetchChannel(channelId).catch(() => null)
  if (!channel) return false          // fail closed
  return channel.serverId === serverId
}

if (!(await channelBelongsToServer(link.channelId, interaction.serverId))) {
  return error('EXTERNAL_LINK')
}

It fails closed. An unresolvable channel, a DM, or a failed lookup all count as “not here”. That is the right default, but it does mean a transient API error now shows up as a link rejection rather than a fetch failure, which we would rather explain than get wrong in the other direction.

The useful property of this fix is that legitimate traffic never touches the new branch. If the check passes, behaviour is exactly what it was before. The only new code path is the rejection, and the only way to reach it is to send a link that was already crossing a boundary. So the blast radius of the fix itself is close to zero, which is not something you often get to say about a change that touches this many call sites.

The second half of the problem

While auditing this we found the same class of bug somewhere less obvious. Some component and modal handlers do not take a link at all. They carry a channel ID inside the custom ID of the component itself, and parse it back out when the interaction comes in:

// custom ID looks like: "edit_<channelId>_<messageId>"
const [, channelId, messageId] = interaction.customId.split('_')
const message = await bot.fetchMessage(channelId, messageId)

A button is fine here, because you can only click a component the bot actually sent. A modal submission is a different story: the custom ID comes back from the client, so anything parsed out of it is untrusted input, exactly like a URL someone typed. Those paths now get the same check.

The lesson we took from that one is narrower and more useful than the original bug: if you put an ID into a custom ID, you are choosing to accept it back as user input later. Keeping the state server-side and using the custom ID purely as a lookup key avoids the question entirely.

What we changed

  • Every command that accepts a message link now binds the channel to the server before touching the message.
  • The autocomplete handlers do the same, so nothing leaks before submission.
  • Modal handlers that read IDs out of a custom ID validate them the same way.
  • Both the current bot and the older codebase it replaced were patched together.

Deployed on July 20, 2026. No action is needed from server admins, and no user data was involved at any point.

Credit

Reported by Vz0n, who found it, explained it clearly, and sent it to us privately first rather than posting it. That is the part that made this a short story instead of a long one.

Their writeup is worth reading in full, not least for the demonstration: the proof of concept was bolting a fake “free Robux” button onto a bot message in a server they had no access to. Which makes the impersonation risk considerably more concrete than any description of it we could write.

Adding free Robux buttons to your messages! · Vz0n/discord_hacking

If you find something similar, please report it privately before making it public. Open a ticket in our Discord server and we will pick it up from there.