How I Fixed Discord's Age Verification Problem

A code-dependent life form.
You know that feeling when you're trying to join an 18+ Discord server, and you have to send a photo of your ID to some random moderator? Yeah, that never sat right with me either.
Discord has this native age verification system, but it's... broken. Not technically broken — it works. But here's the catch: when you verify your age through Discord's official flow, your personal data gets sent to Discord's servers, then shared with the server owner. Your real name, birthday, maybe even your address depending on what ID you use. All that just to prove you're over 18 and access some meme channels.
That seemed ridiculous to me. We have zero-knowledge proofs, we have privacy-preserving cryptography, we have all this amazing tech, and we're still doing age verification like it's 2005?
So I built something better.
Want to follow along? All the code from this article is available on GitHub. Feel free to clone, experiment, and build upon it!
The Problem
You're trying to join a gaming Discord with age-restricted channels. The server admins want to make sure everyone's 18+. Fair enough. But here's what happens:
You submit your government ID to Discord
Discord processes it and confirms your age
Your actual personal information gets shared
That data sits somewhere in their databases forever
You have zero control over what they do with it
That's a lot of personal data floating around in a lot of places. And honestly? Most Discord server owners don't want that responsibility either. They just want to know you're 18+, they don't need your full name and birth date.
This isn't just a privacy issue. It's a liability issue. It's a trust issue.
The "Aha!" Moment
I was reading about Self Protocol one evening (as one does when they're deep in the crypto rabbit hole), and it hit me: what if we could prove we're over 18 without actually revealing our age?
Zero-knowledge proofs let you prove a statement is true without revealing why it's true. In this case: "I am over 18 years old" without saying "I was born on [specific date]".
The Self Protocol takes this further. It lets you scan your actual passport with NFC, generate a cryptographic proof of whatever claims you want to make (age, nationality, etc.), and share that proof without ever revealing the underlying data.
So the server gets: "This person is verified to be 18+, from an allowed country, and not on any sanctions lists."
What the server doesn't get: Your name, birth date, passport number, or anything else.
Perfect.
The Architecture
Let me walk you through how I built this thing. Fair warning: we're going to get technical, but I promise it'll make sense.

Building This Thing Step by Step
Step 1: Setting Up the Self Backend Verifier
First things first: I needed to configure the Self Protocol backend to verify proofs. This is where the magic happens—or at least, where we set up the rules for what "verified" means.
// src/selfVerifier.mjs.
import { SelfBackendVerifier, AllIds, DefaultConfigStore } from "@selfxyz/core";
export const selfBackendVerifier = new SelfBackendVerifier(
SELF_SCOPE, // "demo-scope" - identifies this verification type.
SELF_ENDPOINT, // Our public HTTPS endpoint.
true, // Use staging/mock passports for testing.
AllIds, // Accept all credential types.
new DefaultConfigStore({
minimumAge: 18, // The golden number.
excludedCountries: [], // No country restrictions.
ofac: true, // Check OFAC sanctions lists.
}),
"hex" // User ID format.
);
Let me break down what's happening here:
SELF_SCOPE: Think of this as a namespace. It's how the Self app knows this verification request is for our Discord bot and not some other application.
SELF_ENDPOINT: This is where the Self app will send the proof after the user completes verification. It has to be HTTPS and publicly accessible (no localhost). This is why we need ngrok.
minimumAge: 18: This is the requirement. The Self Protocol will generate a proof that checks if
user_age >= 18without revealing the actual age.ofac: true: This checks the user against the Office of Foreign Assets Control sanctions list. Because even in crypto, we have to play by some rules.
"hex": This tells the verifier we're using hexadecimal format for user IDs. This becomes important when we link Discord user IDs to verification proofs.
The beauty here is that I'm using Self's staging environment with mock passports. This means during development and testing, users don't need real passports, they can create test credentials. Perfect for demos and development.
Step 2: Building the Discord Bot
Now comes the fun part: making Discord actually talk to our verification system.
Initialising the Bot
// src/discordBot.mjs.
import { Client, GatewayIntentBits, Partials, SlashCommandBuilder } from "discord.js";
const client = new Client({
intents: [
GatewayIntentBits.Guilds, // See servers.
GatewayIntentBits.GuildMembers, // See member info.
GatewayIntentBits.GuildMessages, // Read messages.
GatewayIntentBits.DirectMessages, // Send DMs.
GatewayIntentBits.MessageContent, // Read message content.
],
partials: [Partials.Channel], // Needed for DMs.
});
Discord's bot permissions system is... particular. You need to explicitly request each "intent" (permission). I need guild access to see the server, member access to assign roles, message access to respond to commands, and DM access to send the QR codes privately.

The /verify Command
This is where users start their journey. They type /verify in any channel, and the bot springs into action.
async function handleVerifyCommand(interaction) {
const { user, guild } = interaction;
// Make sure they're in a server (not DMing the bot).
if (!guild) {
await interaction.reply({
content: "This command can only be used inside a server.",
flags: MessageFlags.Ephemeral,
});
return;
}
// Check if they're already verified.
const member = await guild.members.fetch(user.id);
if (DISCORD_VERIFIED_ROLE_ID && member.roles.cache.has(DISCORD_VERIFIED_ROLE_ID)) {
await interaction.reply({
content: "You are already verified and should see the restricted channels.",
flags: MessageFlags.Ephemeral,
});
return;
}
// Generate a unique session ID for this verification.
const sessionId = crypto.randomUUID();
// Let them know we're working on it.
await interaction.reply({
content: "Generating your Self verification QR… I'll DM it to you shortly.",
flags: MessageFlags.Ephemeral,
});
// Create the QR code.
const qr = await createSelfVerificationQr(sessionId, user);
// Store this pending verification.
pendingVerifications.set(sessionId, {
discordUserId: user.id,
guildId: guild.id,
createdAt: Date.now(),
qrPath: qr.filePath,
});
// DM them the QR code.
const dm = await user.createDM();
await dm.send({
content: "Scan this QR code with the Self app (staging/mock passports) to verify your age/identity.",
files: [new AttachmentBuilder(qr.filePath)],
});
}
The clever bit here is the session management. Each verification attempt gets a unique UUID. This session ID gets embedded in the QR code, sent to the Self app, included in the proof, sent back to our backend, and used to match the verified proof back to the original Discord user. It's like a claim ticket at a coat check.

Step 3: Generating the QR Code
This is where things get really interesting. We need to create a QR code that:
The Self app can read and understand
Contains our verification requirements (age, OFAC check, etc.)
Includes our session ID so we can match it back to the Discord user
Links the verification to a specific Discord user ID
Here's how:
async function createSelfVerificationQr(sessionId, discordUser) {
// Convert Discord user ID (19-digit number) to hex format.
// This creates a cryptographic commitment linking Discord user to proof.
const hexUserId = BigInt(discordUser.id).toString(16).padStart(40, "0");
const userId = `0x${hexUserId.slice(0, 40)}`;
// Build the Self app configuration.
const selfApp = new SelfAppBuilder({
version: 2,
appName: SELF_APP_NAME,
scope: SELF_SCOPE,
endpoint: SELF_ENDPOINT,
logoBase64: SELF_LOGO_URL,
userId, // Hex-encoded Discord user ID.
endpointType: "staging_https", // Staging environment.
userIdType: "hex", // User ID is hex format.
userDefinedData: JSON.stringify({
kind: "discord-self-verification",
sessionId, // Our session UUID.
discordUserId: discordUser.id, // Original Discord ID.
guildId: DISCORD_GUILD_ID, // Which server this is for.
}),
disclosures: {
minimumAge: 18, // What we require.
excludedCountries: [], // No restrictions.
ofac: true, // Enable OFAC check.
nationality: true, // Request nationality.
gender: true, // Request gender.
},
}).build();
// Generate the Self universal link.
const universalLink = getUniversalLink(selfApp);
// Create QR code as PNG.
const filename = `self-qr-${sessionId}.png`;
const filePath = path.join(qrOutputDir, filename);
await QRCode.toFile(filePath, universalLink, {
width: 512,
errorCorrectionLevel: "H", // High error correction.
});
return { universalLink, filename, filePath };
}
Let me explain the hex user ID thing because it's genuinely clever:
Discord user IDs are massive numbers (like 1439487858766512249). The Self Protocol expects user IDs in hex format. So we convert the Discord ID to hexadecimal, pad it to 40 characters, and prefix it with 0x.
Why? Because this creates a cryptographic commitment. The Self app will include this user ID in the proof. When we verify the proof, we can be absolutely certain it was generated for this specific Discord user—no one else could have generated a valid proof with this user ID.
It's like signing a document that says "I am Discord user #1439487858766512249 and I am over 18" but in a way that's cryptographically unforgeable.

Step 4: The Verification Endpoint
After the user scans the QR code with the Self app and completes the verification flow on their phone, the Self app generates a zero-knowledge proof and sends it to our /api/verify endpoint.
This is where we validate everything:
// index.mjs.
app.post("/api/verify", async (req, res) => {
const { attestationId, proof, publicSignals, userContextData } = req.body;
// Verify the proof using Self's backend verifier.
const result = await selfBackendVerifier.verify(
attestationId,
proof,
publicSignals,
userContextData
);
// Check the verification results.
const { isValid, isMinimumAgeValid, isOfacValid } = result.isValidDetails;
if (!isValid || !isMinimumAgeValid || isOfacValid) {
let reason = "Verification failed";
if (!isMinimumAgeValid) {
reason = "Minimum age verification failed";
} else if (isOfacValid) {
reason = "User is in OFAC sanctions list";
}
return res.status(200).json({
status: "error",
result: false,
reason,
});
}
// Verification succeeded! Extract the session ID from the proof.
const parsed = decodeUserDefinedDataHex(result.userData?.userDefinedData);
if (parsed && parsed.kind === "discord-self-verification" && parsed.sessionId) {
// Match this proof back to the Discord user and assign their role.
await handleDiscordVerificationSuccess(parsed.sessionId);
}
return res.status(200).json({
status: "success",
result: true,
credentialSubject: result.discloseOutput,
});
});
Here's what's happening under the hood:
Proof Verification: The
selfBackendVerifier.verify()call does all the heavy cryptographic lifting. It checks:Is the proof mathematically valid?
Does it prove the user is ≥18 years old?
Is the user on the OFAC sanctions list?
Does the proof match the expected scope and user ID?
OFAC Check: Note the inverted logic here,
isOfacValidbeing true means the user IS on the sanctions list (bad). If it's false, they're clear (good). Confusing naming, but that's how the SDK works, haha.Session Matching: The proof includes our
userDefinedData(remember that from the QR code?). We decode it from hex, extract the session ID, and match it back to our pending verifications.Zero Knowledge: Here's the beautiful part, the proof tells us "this person is over 18" but the actual proof data doesn't contain their birth date. It's a mathematical proof that the statement is true without revealing why it's true.
Step 5: Assigning the Role
Once we've verified the proof and matched it to a Discord user, we assign them the "Verified" role. This is the key that unlocks the age-restricted channels.
export async function handleDiscordVerificationSuccess(sessionId) {
// Look up the pending verification.
const entry = pendingVerifications.get(sessionId);
if (!entry) {
logEvent("verification.unknown_session", "Unknown session", { sessionId });
return;
}
// Clean up the pending verification.
pendingVerifications.delete(sessionId);
const { discordUserId, guildId } = entry;
// Fetch the Discord guild and member.
const guild = await discordClient.guilds.fetch(guildId);
const member = await guild.members.fetch(discordUserId);
// Fetch the verified role.
const role = await guild.roles.fetch(DISCORD_VERIFIED_ROLE_ID);
// Assign it.
await member.roles.add(role);
// Send them a success DM.
const dm = await member.createDM();
await dm.send(
"✅ Your Self verification succeeded. You now have access to the restricted channels."
);
logEvent("verification.role_assigned", "Assigned verified role", {
guildId: guild.id,
discordUserId,
roleId: role.id,
});
}
And just like that, the user can see the restricted channels. No personal data shared. No screenshots of IDs. No trust issues. Just pure cryptographic proof.

The Ngrok Config
The Self app needs to send the proof to your backend. That means your backend needs a public HTTPS URL. Not HTTP. Not localhost. Not a local IP. A real, public, HTTPS URL that the Self app can reach from anywhere.
For local development, that means using a tunneling service like ngrok.
Here's the setup:
# Terminal 1: Start the backend.
cd server
npm run dev
# Terminal 2: Expose it with ngrok.
ngrok http 3001
Ngrok gives you a URL like https://abc123.ngrok-free.app. You copy that, append /api/verify, and put it in your .env file as SELF_ENDPOINT.
The problem? Every time you restart Ngrok, you get a new URL. Free tier limitations. So you have to update your .env and restart your backend.

Pro tip: If you're deploying this for real (not just development), host it on Render, Railway, or anywhere that gives you a stable HTTPS endpoint. Your future self will thank you.
Setting Up Discord Permissions
Here's where a lot of people trip up: Discord's permission system is hierarchical and picky.
You need to:
Create a "Verified" role in your server
Position your bot's role ABOVE the Verified role in the role hierarchy
Give the bot "Manage Roles" permission
Set up channel permissions so only the Verified role can see restricted channels
If your bot's role is below the Verified role, it can't assign it. Discord will silently fail, and you'll be scratching your head wondering why the code isn't working.

Here's how you set it up:
Step 1: Create the Verified Role
Server Settings → Roles → Create Role
Name it "Verified" (or whatever you want)
Copy the Role ID (right-click → Copy Role ID with Developer Mode enabled)

Step 2: Position Your Bot's Role
- Drag your bot's role ABOVE the Verified role in the role list
Step 3: Create Restricted Channels
Create a category called "18+ Only" (or whatever)
Edit Category → Permissions
@everyone: Disable "View Channel"Verifiedrole: Enable "View Channel"
Now only verified users can see those channels. Beautiful.


What Actually Gets Shared
This is important, so let me be crystal clear:
What the Discord Server Owner Gets:
✅ Confirmation that the user is ≥18 years old
✅ Confirmation that the user passed OFAC sanctions check
✅ Optionally: nationality (if disclosed)
✅ Optionally: gender (if disclosed)
What the Discord Server Owner DOES NOT Get:
❌ User's actual age or birth date
❌ User's real name
❌ Passport number
❌ Photo of ID
❌ Address
❌ Any personally identifiable information
What Gets Stored on My Server:
Session IDs (random UUIDs)
Discord user IDs (already public)
QR code images (temporary, can be auto-deleted)
Verification logs (session ID, timestamp, success/failure)
What Gets Stored in the Proof:
Cryptographic proof of age ≥18 (mathematical proof, not actual age)
OFAC status (boolean: sanctioned or not)
Disclosed attributes (if user chose to share nationality/gender)
Session ID (UUID we generated)
Hex-encoded Discord user ID (for linking proof to user)
The proof itself is essentially a mathematical statement: "I possess a valid passport that proves I am ≥18 years old" without revealing what's on the passport.
How Zero-Knowledge Proofs Work Here
For the nerds in the audience (I see you), let me explain what's actually happening cryptographically.
The Proof Generation (On the User's Phone)
When someone scans their passport with the Self app:
NFC Read: The app reads the passport's NFC chip. Modern passports have digitally signed data (called "passive authentication").
Data Extraction: The app extracts relevant fields:
Birth date
Nationality
Document number
Issuing country
Expiration date
Proof Circuit Execution: The Self app runs a zero-knowledge proof circuit that:
Verifies the passport signature is valid (proves it's a real passport)
Computes
current_date - birth_date >= 18 yearsChecks nationality against excluded countries list (empty in our case)
Checks against OFAC sanctions database
All without revealing the actual values
Public Signals: The proof includes public outputs:
minimumAgeValid: true/false (whether age ≥18)ofacValid: true/false (whether on sanctions list)userId: The hex-encoded Discord user ID we providedscope: Our verification scope ("demo-scope")
Private Inputs: These stay secret in the proof:
Actual birth date
Full name
Passport number
Full passport data
The magic is that the proof is mathematically verifiable but reveals nothing about the private inputs.
The Verification (On Our Backend)
When our backend receives the proof:
const result = await selfBackendVerifier.verify(
attestationId, // Unique proof identifier.
proof, // The actual ZK proof (array of field elements).
publicSignals, // Public outputs from the circuit.
userContextData // Metadata about the credential.
);
The verifier checks:
Proof Validity: Does the proof mathematically check out?
Public Signal Matching: Do the public outputs match our requirements?
minimumAgein signals ≥ 18scopematches our configured scopeuserIdmatches the expected Discord user ID
Timestamp Checks: Is the proof fresh? (Prevents replay attacks)
Circuit Hash: Does the proof come from the expected circuit? (Prevents proof substitution)
If all checks pass, we know with cryptographic certainty that:
The user has a valid passport
The passport proves they're ≥18
The proof was generated for this specific Discord user
The proof hasn't been tampered with or replayed
All without ever seeing the passport data itself.

Real-World Considerations and Edge Cases
Building this was educational. Here are some gotchas I ran into:
1. Session Cleanup
Right now, pending verifications stay in memory until the verification completes or the server restarts. For production, you'd want:
// Clean up stale sessions after 15 minutes.
setInterval(() => {
const now = Date.now();
for (const [sessionId, entry] of pendingVerifications) {
if (now - entry.createdAt > 15 * 60 * 1000) {
pendingVerifications.delete(sessionId);
// Optionally delete the QR code file too.
}
}
}, 60 * 1000); // Run every minute
2. Rate Limiting
Someone could spam /verify and generate tons of QR codes. Add rate limiting:
const verifyAttempts = new Map(); // userId -> timestamp of last attempt.
async function handleVerifyCommand(interaction) {
const lastAttempt = verifyAttempts.get(interaction.user.id);
if (lastAttempt && Date.now() - lastAttempt < 60000) {
await interaction.reply({
content: "Please wait a minute before trying again.",
flags: MessageFlags.Ephemeral,
});
return;
}
verifyAttempts.set(interaction.user.id, Date.now());
// ... rest of verification logic.
}
3. QR Code Storage
The QR codes pile up in server/qrcodes/. For production:
Delete QR codes after successful verification
Or store them in temporary memory instead of disk
Or use S3/Cloud Storage with auto-expiration
4. Proof Replay Protection
Self Protocol includes timestamp checks, but you could add additional protection:
const usedProofs = new Set();
app.post("/api/verify", async (req, res) => {
const { attestationId } = req.body;
if (usedProofs.has(attestationId)) {
return res.json({ status: "error", reason: "Proof already used" });
}
// ... verify proof.
usedProofs.add(attestationId);
// Clean up old proof IDs after 1 hour.
setTimeout(() => usedProofs.delete(attestationId), 60 * 60 * 1000);
});
5. Error Handling and Logging
I built a JSON-line logger that records everything:
// src/logger.mjs.
export function logEvent(type, message, metadata = {}) {
const entry = {
timestamp: new Date().toISOString(),
type,
message,
...metadata,
};
const logLine = JSON.stringify(entry);
fs.appendFileSync(logFilePath, logLine + "\n");
}
This makes it easy to grep logs, parse them programmatically, or ship them to a logging service:
# See all verification failures.
cat server/logs/discord-verifier.log | grep "verification.failed"
# Count successful verifications.
cat server/logs/discord-verifier.log | grep "verification.succeeded" | wc -l

Deployment Checklist
If you want to actually deploy this (and you should, it's cool), here's what you need:
Environment Variables
# Server.
PORT=3001
# Self Protocol.
SELF_SCOPE=your-unique-scope
SELF_ENDPOINT=https://your-domain.com/api/verify
SELF_APP_NAME=Your Discord Verification
SELF_LOGO_URL=https://your-domain.com/logo.png
# Discord.
DISCORD_BOT_TOKEN=your_bot_token_here
DISCORD_CLIENT_ID=your_client_id_here
DISCORD_GUILD_ID=your_server_id_here
DISCORD_VERIFIED_ROLE_ID=your_verified_role_id_here
Discord Developer Portal Setup
Create application at https://discord.com/developers/applications
Create bot and copy token
Enable Privileged Gateway Intents:
Server Members Intent
Message Content Intent
Generate invite URL with scopes:
bot
applications.commands
Bot permissions:
View Channels
Send Messages
Manage Roles
Production vs. Staging
I built this using Self's staging environment with mock passports. For production:
Change
endpointTypefrom"staging_https"to"https"Update the verifier constructor:
new SelfBackendVerifier( SELF_SCOPE, SELF_ENDPOINT, false, // false = use real passports, not mocks. AllIds, // ... rest of config. )Users will need to scan their real passports via NFC
This means they'll need:
A phone with NFC capability
A modern passport with an NFC chip (most issued after 2006)
The Self app installed
Why This Matters
This isn't just about Discord age verification. It's about a fundamental shift in how we think about identity online.
Right now, the internet runs on trust:
You trust Discord with your ID
You trust server owners with your data
You trust that databases won't be breached
You trust that people won't misuse your information
But we don't have to. Cryptography gives us a better way.
Zero-knowledge proofs let us verify facts about people without learning those facts. In this case: "Are you 18?" → "Yes" (with mathematical proof) vs. "What's your birth date?" → "January 15, 1995" (requires trust).
Try It Yourself
Seriously, go try this. The setup takes about 20 minutes if you follow this:
Final Thoughts
Thanks for reading! If you build something cool with this, I'd love to hear about it. If you have questions, open an issue on GitHub. If you find this useful, give it a star.
If you ever need help or just want to chat, DM me on Twitter / X or LinkedIn.





