A Discord verification bot that confirms a member owns a real @umn.edu
email address, remembers them, and auto-assigns the "verified" role in
any server running Gopherfy where that member shows up later.
Built by Eric He and Ravindu Ranasinghe.
Enable Secret scanning (and push protection if available) for this repository: Settings → Code security and analysis. See About secret scanning. CI also runs gitleaks on every workflow.
When someone joins a Discord server running Gopherfy, they start with no
verified role and only see a verification channel. They click Start
verification on the panel, enter their @umn.edu email, receive a
6-digit code by email, and submit the code back to the bot. If the code
matches, the bot stores the link between their Discord account and their
UMN email and grants the verified role.
The next time that same Discord user joins a different server running Gopherfy, the bot recognizes them from the shared database and assigns the verified role automatically — no second email round-trip.
Mods can run /whois @user to see which @umn.edu address a member
verified with.
Gopherfy uses a single SQLite database (verified.db) with two tables,
defined in src/bot/db.js:
CREATE TABLE verified_users (
discord_id TEXT PRIMARY KEY,
email_hmac TEXT UNIQUE,
verified_at INTEGER
);
CREATE TABLE guild_config (
guild_id TEXT PRIMARY KEY,
verified_role_id TEXT NOT NULL,
unverified_role_id TEXT NOT NULL,
configured_at INTEGER
);verified_users is the cross-server memory. discord_id is the
primary key so each Discord account can only ever be linked to one
email, and email_hmac UNIQUE ensures the reverse — one UMN identity
can only verify one Discord account. Gopherfy never stores the
plaintext email address; it stores an HMAC-SHA256 digest
(OTP_HMAC_KEY) so the database contains no PII. Attempts to reuse an
email on a second account are still caught — the bot computes the HMAC
of the submitted address and checks for a collision before an OTP is
even sent.
guild_config stores per-server settings — which role to grant after
verification — set by an admin running /setup.
When a user joins a new server, the bot's guildMemberAdd handler
looks up their Discord ID in verified_users; if present, it grants
the configured verified role immediately.
SQLite comfortably handles this schema at the scale Gopherfy is aimed at (thousands of UMN students across many servers) on a single host.
Gopherfy is split into two processes:
- The bot (
src/bot/) — talks to Discord, owns the database, and handles slash commands, buttons, and modals. - The OTP service (
src/otp-service/) — a small HTTP service that generates 6-digit codes, sends them via Resend, and verifies what the user submits.
The two communicate over HTTP. When a user enters their email, the bot
POSTs to /send on the OTP service; when the user submits their code,
the bot POSTs to /verify. The OTP service returns only a yes/no plus
the email on success — the bot never sees the code itself.
- Bot validates the address ends in
@umn.eduand that the email isn't already linked to a different Discord account. - Bot calls
POST /sendwith the Discord ID and email. - OTP service checks the per-user send rate limit (3 per hour).
- It generates a 6-digit code with
crypto.randomInt. - It hands the code to Resend, which delivers the email.
- Only if Resend accepts the send does the service store the code in memory and consume a rate-limit slot. Failed sends leave no trace — a legitimate user is never locked out because of an upstream email outage.
- Bot calls
POST /verifywith the Discord ID and the submitted code. - OTP service looks up the pending code for that Discord ID.
- It compares in constant time (
crypto.timingSafeEqual). - Each pending OTP allows at most 5 wrong guesses — on the fifth wrong code the entry is deleted and the user has to request a new one.
- On success, the service returns the verified email, the bot writes
(discord_id, email, now)intoverified_users, and the user gets the role.
Codes live for 10 minutes and are held in memory (not in SQLite) — they're ephemeral by design and don't need to survive a restart of the OTP service.
Splitting email out of the bot means:
- The bot can be restarted (for code updates, Discord gateway reconnects, role changes) without losing in-flight verification attempts — or, the reverse, the OTP service can be restarted without dropping the bot's connection to Discord.
- The Resend API key is only used by the OTP service (from
.envin development, or Secret Manager in production — never by the Discord bot). - If Gopherfy ever needs to swap email providers, only one small service changes.
The two services authenticate to each other with HMAC-SHA256
request signing over the raw JSON body plus a timestamp
(OTP_SERVICE_KEY). Without a valid signature, the OTP service refuses
every request — so even if the service port is reachable on the host
network, nobody can request codes to arbitrary addresses or brute-force
someone else's pending code.
In production (NODE_ENV=production), the four sensitive values
(DISCORD_TOKEN, OTP_SERVICE_KEY, OTP_HMAC_KEY, RESEND_API_KEY)
are read from Google Cloud Secret Manager instead of process.env.
- Enable the Secret Manager API on your GCP project.
- Create secrets whose names match those env keys exactly. Store
one value per secret; runtime reads
…/versions/latest(see Secret rotation below). - Create a service account for the workload (for example the GCE
VM). Grant it
roles/secretmanager.secretAccessoron each secret (or on the project if you accept broader scope). - Attach that service account to the GCE instance. Do not pass
the four secrets as VM environment variables or in cloud-init. Use
env only for non-secret config (
LOG_LEVEL,FROM_EMAIL,OTP_SERVICE_URL, rate-limit tuning,GCP_PROJECT_ID, etc.). - Set
GCP_PROJECT_IDon the VM. The Node client uses Application Default Credentials; on GCE this is the attached service account. Do not deploy a downloaded JSON key file for production.
Local development: use NODE_ENV=development (or leave unset) and
keep the four values in .env as today; Secret Manager is not used.
npm run deploy:commands uses loadSecrets() for DISCORD_TOKEN; you
still need CLIENT_ID in the environment.
Add a new version of a secret in the GCP console and retire the old
version when ready. Gopherfy always reads versions/latest, so restart
the bot and OTP processes after rotation to pick up new material.
For some workloads Google recommends pinning a specific version
(e.g. versions/5) instead of latest, so an accidentally re-enabled
old version cannot change behavior silently. Gopherfy defaults to
latest for simplicity; if you need pins, adjust the resource path in
src/lib/secrets.js and redeploy.
| Command | Who | What |
|---|---|---|
/setup verified-role:<role> |
Server admins | One-time per-server config |
/verify-panel |
Mods | Post the button-driven verification panel |
/verify [email] |
Everyone | Start verification (slash-command flow) |
/code <digits> |
Everyone | Submit the 6-digit code |
/whois <user> |
Mods | Look up which UMN email a member verified with |
/whois-audit |
Server admins | Show recent /whois lookups grouped by moderator |
/forget-me |
Everyone | Delete your verification record and remove verified roles |
Most users go through the panel's Start verification / Submit code buttons rather than the slash commands directly.
Local CI parity — before pushing, run:
npm run verifyThis runs npm ci → lint → format:check → test --ci → audit, mirroring
the three CI jobs exactly. If it's green locally it will be green in CI.
A pre-push hook (.husky/pre-push) runs npm run verify
automatically on every git push, blocking the push if any check fails.
Use git push --no-verify to bypass when genuinely needed (e.g. a
work-in-progress draft push).
- Eric He
- Ravindu Ranasinghe
- Ritesh Prabhu