DiscordPHP Bot Security Skill
A concrete checklist built from real findings across DiscordPHP-MTG,
DiscordPHP-NHA, DiscordPHP-Voice and Civilizationbot.
1. Never send the Discord bot token to a third party
The classic bug: an extension's Http client for api.example.com is
constructed with 'Bot '.$this->token and adds Authorization: <token> to
every request — so the Discord bot token is sent to an unrelated host on
every call.
- Extension HTTP clients for a third-party API should take an empty token and
only set
Authorizationwhen a real value is present:$h = ['User-Agent' => $this->getUserAgent()]; if ($this->token !== '') $h['Authorization'] = $this->token; if ($this->apiKey !== null) $h['X-Api-Key'] = $this->apiKey; // that API's own key - Drop Discord-only headers (
X-Ratelimit-Precision) from non-Discord clients. - Override
getUserAgent()to identify your library, notDiscordPHP-HTTP.
2. Keep tokens out of logs and exception messages
- Do not log full request/response bodies on error. A FastAPI-style 422 body
echoes the request back under
input— for an auth'd POST that includes the token. Redact before logging and before putting it in an exception message:$body = preg_replace('/("(?:nha_)?token"\s*:\s*)"[^"]*"/i', '$1"***"', $body) ?? $body; Manager/gateway code: log['token' => '*****'], and give payload parts a__debugInfo()that redacts the token (seeVoicePayload::__debugInfo()). Log identify with['op' => $payload->op], not the whole payload.- Global error handlers that DM a technician: send
file:line:functionfromdebug_backtrace()(no['args']).getTraceAsString()still inlines scalar args truncated to 15 chars — a token prefix can leak. Prefer the arg-free form.
3. Keep secrets out of custom_id
Component custom_ids are sent to Discord and visible in the client. Capture the
agent/session token in the server-side listener closure, never in the id or a
button label. (NHA's AgentObservation::toContainer($nha, $token) does this
right — $token lives only in the $submit closure.)
4. OAuth2 / CSRF / webhooks
- State / session ids:
bin2hex(random_bytes(16)), neveruniqid()(predictable microtime). - Compare with
hash_equals(), not===/!==— HMAC signatures and CSRF state tokens. Reject empty/absent values up front; make the state single-use (delete it after a successful exchange). - Webhook signature: verify with
hash_equals(), preferX-Hub-Signature-256, and fail closed when the shared secret env var is unset (an unset secret makeshash_hmackey''and every request "valid"). - Redirect URIs: always validate the effective
redirect_uriagainst an allow-list — a caller-supplied value must not bypass the check (open redirect / auth-code interception). - Don't log the OAuth authorization
code(an exchangeable credential), the full request header set on a rejected request (a mistypedAuthorizationlands in the log), or contact-form email/message bodies (PII — send them to the private channel, not the log). - Raw
curlin an OAuth path: setCURLOPT_SSL_VERIFYPEER => true,CURLOPT_SSL_VERIFYHOST => 2,CURLOPT_CONNECTTIMEOUT,CURLOPT_TIMEOUT,curl_close(), and null-check the result.
5. Repo hygiene
.gitignoremust cover.env,.env.*,composer.lock(in these repos),/var/*(state files with tokens), and any bespoke secret file (token.php,/json,botlog.txt).- Verify nothing sensitive is tracked:
git ls-files | grep -iE '\.env|token|secret|\.pem|\.key|credential'. - Never place personal data or tokens in URL query strings (they get logged by
proxies and land in
Referer).
Quick grep sweep
rg -n "addQuery\(\s*['\"](token|key|secret|password)" src/ # token in URL
rg -n "(debug|info|warn|error).*(token|Authorization|getBody|payload|->data\b)" src/
rg -n "!==|===" src/ | rg -i "hash_hmac|signature|hash_equals" # non-constant-time
rg -n "uniqid\(" src/ # weak randomness