Security
Keep the token secret, ask for few permissions, check who may run a command, and distrust what members send.
You'll learn
- Keep the token out of code, images and logs, and reset it if it leaks
- Ask for only the intents and permissions the bot uses
- Check permissions and who clicked when a command runs
- Treat options, fields and messages as untrusted
Before this
A bot acts with its own permissions, on behalf of anyone who can use its commands. This page collects what keeps that safe: the token, the permissions it asks for, who may run what, and how it treats what members send it.
When to use it
Before the bot joins servers you don't run, and whenever a command acts for someone: bans, posts, reads or writes data. Most of it is a line or a guard; the rest is a habit.
Example
A guard checks, when a command runs, that the member holds the permissions it needs:
const RequiredPermission = createMetadata<bigint>('requiredPermission')
// Checks the member's permissions on every call, whatever a server has changed in its command settings
@Guard()
export class PermissionGuard implements GuardInterface {
constructor(private readonly context: ExecutionContext) {}
canActivate(interaction: ChatInputCommandInteraction): boolean {
const permission = this.context.get(RequiredPermission)
if (permission === undefined || interaction.memberPermissions?.has(permission)) return true
throw new GuardDeniedError('You don’t have the permission this command needs.')
}
}
export const RequirePermission = (permission: bigint) =>
applyDecorators(RequiredPermission(permission), UseGuard(PermissionGuard))The command keeps its default for the menu, and the guard enforces it:
@CommandBuilder(CommandType.SLASH)
export class SayCommandBuilder {
build(commandName: string) {
return (
new SlashCommandBuilder()
.setName(commandName)
.setDescription('Post a message as the bot')
// Who sees the command by default; a server's admins can change it
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.setContexts(InteractionContextType.Guild)
.addStringOption(option =>
option.setName('message').setDescription('What to post').setMaxLength(2000).setRequired(true),
)
)
}
}setDefaultMemberPermissions decides who sees a command by default, but a server's admins can change that under
Server Settings → Integrations, for any command. Treat it as a default, and check what a command needs when it
runs, as the guard does.
How it works
The guard runs before the handler, as every guard does, and refuses the call when a permission is
missing. interaction.memberPermissions is null outside a server, so the guard refuses those calls too;
setContexts(InteractionContextType.Guild) keeps the command out of DMs in the first place.
The token
The token is the bot's password: anyone who has it can act as the bot in every server it's in.
- Keep it in the environment.
meocord.config.tsreadsprocess.env.DISCORD_TOKEN, and a new project's.gitignoreleaves every.envfile except.env.exampleout of git. - Never paste it into code, a Dockerfile, a build argument, an issue or a log. Tests need no token.
- If it leaks, reset it in the Developer Portal, under Bot. The old token stops working at once; update the environment and restart.
Ask for little
- Intents. List only the intents the bot uses. The privileged ones, Message Content, Server Members and Presence, are switched on in the Developer Portal, and a bot in 100 or more servers needs Discord's approval for them.
- Permissions. Invite the bot with the permissions its commands use, never Administrator. A bot with Administrator that's tricked into acting does so with every permission a server has.
- Mentions. Tell discord.js who a message may ping. A client-wide default is a line in the app:
@MeoCord({
controllers: [SayController],
clientOptions: {
// Only the intents the bot uses; privileged ones need a reason and Discord's approval
intents: [GatewayIntentBits.Guilds],
// The default for every message: members can be pinged, @everyone and roles never
allowedMentions: { parse: ['users'], repliedUser: false },
},
})
export default class App {}Components carry no authority
A button's custom ID is written by the bot, but anyone who can see the message can click it. Before a button acts for someone, check who clicked: a guard that compares the clicking user with the ID the custom ID carries, as in the pagination recipe, or one that checks a role, as the feedback bot's staff guard does in Guards. Keep secrets out of custom IDs: anyone who inspects the message can read them.
A select menu's values are what the client sent. Act only on values the bot offered, as the select menu recipe does.
What members send
Treat every option, modal field and message as untrusted:
- Limit it.
setMaxLengthon string options and text inputs, andsetMinValueandsetMaxValueon numbers, have Discord refuse what doesn't fit. Validation checks the rest before the handler runs. - Echo it without pings. Text a member wrote can hold
@everyoneor a role mention. Posting it withallowedMentions: { parse: [] }shows it as written and pings no one:
@Controller()
export class SayController {
@Command('say', SayCommandBuilder)
@RequirePermission(PermissionFlagsBits.ManageMessages)
async say(interaction: ChatInputCommandInteraction, { message }: { message: string }) {
// Posts the text as written, and pings no one: not @everyone, not a role, not a member
await respond(interaction).send({ content: message, allowedMentions: { parse: [] } })
}
}- Never run it. No
eval, and no shell command built from input. Pass values to a database as parameters, as the database recipe does, never by joining them into the query.
The test proves both the refusal and the absence of pings:
describe('SayController', () => {
const module = MeoCordTestingModule.create({ controllers: [SayController] }).compile()
const say = (permissions: bigint[]) =>
createMockInteraction(ChatInputCommandInteraction, {
guildId: '1',
memberPermissions: new PermissionsBitField(permissions),
options: createChatInputOptions({ message: '@everyone free nitro' }),
})
it('posts the text without pinging anyone', async () => {
const interaction = say([PermissionFlagsBits.ManageMessages])
await module.invoke(SayController, 'say', interaction)
expect(getResponse(interaction).calls[0].payload).toMatchObject({
content: '@everyone free nitro',
allowedMentions: { parse: [] },
})
})
it('refuses a member without Manage Messages, even where a server has opened the command to them', async () => {
const interaction = say([])
await expect(module.invoke(SayController, 'say', interaction)).rejects.toThrow(GuardDeniedError)
expect(getResponse(interaction).calls).toEqual([])
})
})Errors, privacy and abuse
MeoCord's built-in fallback logs the full error and tells the member only that something went wrong, so a stack trace or a query never reaches Discord. Keep it that way in your own exception filters and guard messages: say what the member can do about it, not how the bot failed.
Answer with anything personal privately, with flags: MessageFlags.Ephemeral. Log IDs rather than message content, and
never log the config, which holds the token. MeoCord's Logger prints the token as [redacted] wherever it appears;
console.log does not.
A command that's expensive, or that posts where others see it, takes a cooldown. per: 'guild'
limits a whole server, for a command that costs the bot the same whoever runs it.
Dependencies
Install from the lockfile with npm ci, keep discord.js and MeoCord up to date, and run npm audit, or your package
manager's equivalent, in CI. A self-contained build ships exactly the packages it was
built with.
Gotchas
setDefaultMemberPermissionsis a default, which a server's admins can change. Check permissions in a guard.- A button's custom ID says nothing about who clicked. Compare the user, or check a role.
- Echoing a member's text pings whoever it mentions unless the message sets
allowedMentions.
Build it
The feedback bot runs on its own server. Invite it to the server where it's used, with only what it needs: the bot and
applications.commands scopes, View Channel, Send Messages and Embed Links in the review channel and
wherever members mention it, Read Message History where they mention it, and View Channel and Send Messages
in the server's system channel for its welcome. It needs no privileged intents: Guilds, GuildMessages and
GuildMessageReactions are all it asks for.
Make the review channel visible to the staff alone. The guard stops anyone else from deciding, but anyone who can see the channel can read the feedback.
Run /feedback as a member: the report reaches the review channel, and a member without the staff role neither sees
it nor can decide it.