Skip to main content

Discord Bot Permissions & Security: Best Practices

Bot security protects your bot from misuse, prevents data leaks, and ensures commands run only by authorized users. Key practices include storing tokens securely, validating user permissions before executing sensitive commands, rate-limiting to prevent abuse, and logging actions for auditing. Discord's permissions system (roles, channel overrides, bot scopes) is the foundation; Python's decorator-based checks enforce them in code. Understanding security patterns teaches you threat modeling, defense-in-depth, and responsible API access—essential for any production application.

Token Management: The First Defense

Your bot token is your bot's password. Never hardcode it or commit it to version control. Use environment variables (covered in article 1):

import os
from dotenv import load_dotenv

load_dotenv()
token = os.getenv('DISCORD_TOKEN')

if not token:
raise ValueError('DISCORD_TOKEN environment variable not set')

bot.run(token)

Create a .env file in your project root:

DISCORD_TOKEN=your_actual_token_here

Add .env to .gitignore:

.env
.env.local
*.key

If your token is accidentally exposed (committed to GitHub, leaked in a screenshot), immediately regenerate it in the Discord Developer Portal. The old token is invalidated within seconds.

Permission Checking: Role and User Validation

Enforce permissions before running sensitive commands:

import discord
from discord.ext import commands
import os
from dotenv import load_dotenv

load_dotenv()
bot = commands.Bot(command_prefix='!', intents=discord.Intents.default())

# Owner-only commands
@bot.command(name='shutdown')
@commands.is_owner()
async def shutdown(ctx):
"""Shut down the bot (owner only)."""
await ctx.send('Shutting down...')
await bot.close()

# Role-based checks
@bot.command(name='ban')
@commands.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
async def ban(ctx, member: discord.Member, *, reason='No reason'):
"""Ban a member (requires Ban Members permission)."""
await member.ban(reason=reason)
await ctx.send(f'{member.mention} has been banned.')

# Custom permission check
def is_moderator(ctx):
"""Check if the user has a Moderator role."""
mod_role = discord.utils.get(ctx.guild.roles, name='Moderator')
return mod_role in ctx.author.roles

@bot.command(name='warn')
@commands.check(is_moderator)
async def warn(ctx, member: discord.Member):
"""Warn a user (Moderator role only)."""
await ctx.send(f'{member.mention} has been warned.')

# Multiple checks (all must pass)
@bot.command(name='purge')
@commands.has_any_role('Moderator', 'Admin')
@commands.bot_has_permissions(manage_messages=True)
async def purge(ctx, limit: int = 10):
"""Delete the last N messages (Moderator or Admin only)."""
await ctx.channel.purge(limit=limit)
await ctx.send(f'Deleted {limit} messages.', delete_after=5)

Key decorators:

  • @commands.is_owner() — only the bot owner
  • @commands.has_permissions(permission_name=True) — user has permission
  • @commands.bot_has_permissions(permission_name=True) — bot has permission
  • @commands.has_role('Role Name') — user has a specific role
  • @commands.has_any_role('Role1', 'Role2') — user has at least one role
  • @commands.check(callable) — custom validation function

Error Handling for Permission Failures

When a permission check fails, provide helpful feedback:

@bot.command(name='delete_channel')
@commands.has_permissions(manage_channels=True)
@commands.bot_has_permissions(manage_channels=True)
async def delete_channel(ctx, channel: discord.TextChannel):
"""Delete a channel."""
await channel.delete()
await ctx.send(f'Channel {channel.mention} has been deleted.')

@delete_channel.error
async def delete_channel_error(ctx, error):
"""Handle errors for the delete_channel command."""
if isinstance(error, commands.MissingPermissions):
perms = ', '.join(error.missing_permissions)
await ctx.send(
f'You need the following permission(s): {perms}',
ephemeral=True
)
elif isinstance(error, commands.BotMissingPermissions):
perms = ', '.join(error.missing_permissions)
await ctx.send(
f'I need the following permission(s): {perms}',
ephemeral=True
)
else:
await ctx.send(f'Command failed: {str(error)}', ephemeral=True)

Use ephemeral=True to hide error messages from other users. Include the specific missing permissions so users understand what they need.

DM Restrictions and Rate-Limiting

Prevent abuse by restricting certain actions:

from discord.ext.commands import cooldown, BucketType

# Prevent DM commands (guild-only)
@bot.command(name='announce')
@commands.guild_only()
@commands.has_permissions(administrator=True)
async def announce(ctx, *, message: str):
"""Announce a message to the channel (guild admins only)."""
await ctx.send(message, view=discord.ui.View())

# Rate-limiting
@bot.command(name='lottery')
@cooldown(1, 60, BucketType.user) # 1 use per 60 seconds per user
async def lottery(ctx):
"""Enter daily lottery (once per minute per user)."""
import random
if random.random() < 0.1:
await ctx.send(f'{ctx.author.mention} won 1000 coins!')
else:
await ctx.send(f'{ctx.author.mention} did not win.')

@lottery.error
async def lottery_error(ctx, error):
if isinstance(error, commands.CommandOnCooldown):
await ctx.send(
f'Try again in {error.retry_after:.0f}s.',
ephemeral=True
)

# Prevent command in DMs
@bot.command(name='private')
@commands.guild_only()
async def private(ctx):
"""This command only works in guilds."""
await ctx.send('This is a guild-only command.')

@commands.guild_only() prevents execution in DMs. @cooldown() enforces rate limits. BucketType.user means per-user limits; other options are guild, channel, member, or default (global).

Logging and Auditing

Log sensitive actions for accountability:

import logging
from datetime import datetime

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Log important events
@bot.event
async def on_ready():
logger.info(f'{bot.user} connected at {datetime.now()}')

@bot.event
async def on_command(ctx):
logger.info(f'{ctx.author.name} executed {ctx.command.name}')

@bot.event
async def on_command_error(ctx, error):
logger.error(f'Command {ctx.command.name} failed: {error}')

# Log dangerous actions
@bot.command(name='kick')
@commands.has_permissions(kick_members=True)
async def kick(ctx, member: discord.Member, *, reason='No reason'):
"""Kick a member."""
logger.warning(
f'{ctx.author.name} kicked {member.name} '
f'from {ctx.guild.name}: {reason}'
)
await member.kick(reason=reason)
await ctx.send(f'{member.mention} has been kicked.')

# Log to a channel (audit log)
@bot.event
async def on_member_ban(guild, user):
"""Log bans to a designated log channel."""
log_channel = discord.utils.get(guild.channels, name='mod-logs')

if log_channel:
embed = discord.Embed(
title='Member Banned',
description=f'{user.mention} ({user.name}) was banned.',
color=discord.Color.red(),
timestamp=datetime.utcnow()
)
await log_channel.send(embed=embed)

logger.warning(f'{user.name} was banned from {guild.name}')

Log to both file (using Python's logging module) and Discord channels. This creates a permanent record for auditing and debugging.

Validating User Input

Always validate and sanitize user input to prevent injection attacks:

import re

# Validate channel names (alphanumeric and hyphens only)
def is_valid_channel_name(name: str) -> bool:
return bool(re.match(r'^[a-z0-9-]{1,32}$', name))

@bot.command(name='createchannel')
@commands.has_permissions(manage_channels=True)
async def createchannel(ctx, *, name: str):
"""Create a channel with a validated name."""
if not is_valid_channel_name(name):
await ctx.send(
'Channel name must be 1-32 characters, '
'alphanumeric or hyphens only.'
)
return

try:
await ctx.guild.create_text_channel(name)
await ctx.send(f'Channel `#{name}` created.')
except discord.Forbidden:
await ctx.send('I don\'t have permission to create channels.')

# Limit message length
@bot.command(name='say')
@commands.has_permissions(administrator=True)
async def say(ctx, *, message: str):
"""Echo a message (admin only, max 2000 chars)."""
if len(message) > 2000:
await ctx.send('Message must be 2000 characters or less.')
return

await ctx.send(message)

Validate input length, format, and type. Discord enforces a 2000-character message limit; enforce your own limits if needed. Check for special characters that could cause injection.

Principle of Least Privilege

Grant your bot only the permissions it needs. In the Developer Portal, under OAuth2 → URL Generator, select:

  • Scopes: only bot (not applications.commands unless you use slash commands)
  • Permissions: minimum required (e.g., for a music bot: Connect, Speak, Manage Messages)

Too many permissions create a larger attack surface. If a token is compromised, an attacker's actions are limited.

Security PracticeBenefitImplementation
Token in .envPrevents accidental leaksload_dotenv(), os.getenv()
Permission checksOnly authorized users run commands@commands.has_permissions(), @commands.check()
Rate-limitingPrevents abuse and spam@cooldown()
Audit loggingTracks who did what and whenFile logging + Discord log channel
Input validationPrevents injection attacksRegex, length checks, sanitization
Least privilegeLimits damage if token is compromisedMinimal OAuth2 scopes and Discord permissions

Key Takeaways

  • Store bot tokens in .env files using environment variables; never hardcode or commit them.
  • Use @commands.has_permissions(), @commands.is_owner(), and custom @commands.check() decorators to enforce authorization.
  • Provide helpful error messages when permission checks fail; use ephemeral=True to hide sensitive information.
  • Apply @cooldown() to prevent spam and rate-limit abuse.
  • Log all administrative actions to file and Discord channels for accountability and debugging.
  • Validate and sanitize user input to prevent injection attacks.
  • Request only necessary OAuth2 scopes and Discord permissions.

Frequently Asked Questions

What should I do if my bot token is leaked?

Immediately regenerate it in the Developer Portal. The old token is invalidated within seconds. If the bot was public, audit your database for unauthorized changes.

Can I use the same bot token for multiple bot instances?

No, one token = one bot instance. To run multiple bots, create separate applications with separate tokens.

How do I prevent users from using my bot commands to spam other servers?

Use @commands.cooldown() to rate-limit per user. For global limits, store timestamps in a database and check against them.

What permissions should a moderation bot have?

Typically: Manage Messages, Kick Members, Ban Members, Mute Members, Manage Roles. Grant only what you use.

How do I audit who used a command?

Log with ctx.author.id, ctx.author.name, and ctx.command.name. Store in a database or log file with timestamps.

Further Reading