Building Forms That Actually Respect Your Privacy

A code-dependent life form.
Search for a command to run...

A code-dependent life form.
No comments yet. Be the first to comment.
Master privacy-first development with Self Protocol. Use zero-knowledge proofs to verify real humans without personal data. Includes tutorials, source code, architecture patterns, and production best practices.
So there I was, excited to integrate Self, this awesome privacy-first identity verification protocol into my project. Self uses zero-knowledge proofs to verify real human identities without exposing sensitive data. Perfect for preventing bots, protec...
The first time I held my phone against my passport and watched an app pull my own photo off the chip, I had two reactions. First: that's really cool. Second: wait, how did it do that? So I went diggin

I just finished rebuilding our docs at docs.self.xyz, and the whole thing came from one assumption: every page now gets read twice. Once by a developer, and once by the agent sitting next to them. If

The final part of a series on agent harnesses. We started with the model-plus-harness idea, then built subagents, gave them a safe place to work, and wired up tools, memory, and workflows. This last p

Part four of a series on agent harnesses. The earlier pieces covered the model-plus-harness idea, subagents, and sandboxes. This one is about the three things that turn a capable agent into one that e

Part three of a series on agent harnesses. Part one introduced the model-plus-harness idea, and part two split work across subagents. This piece is about the layer where agents stop just talking and s

Look, we've all been there. You fill out a "totally anonymous" survey, click submit, and somehow you're getting targeted ads about your responses the next day. Companies love to slap an "anonymous" badge on their forms while quietly collecting your IP address, browser fingerprint, device ID, and enough metadata to practically write your biography.
That's the problem I wanted to solve. And honestly? I was tired of the broken promises.
Want to follow along? The complete source code for this project is available on GitHub:
https://github.com/kartikmehta8/self-truely-anon-forms
Here's what really grinds my gears: companies claim their forms are anonymous, but they're actually hoarding data like digital dragons. Every time you submit a form online, here's what's typically getting captured behind the scenes:
Your IP address (which can be traced to your location)
Browser fingerprinting (tracking you across the web)
Device identifiers (yes, they know it's your phone)
Cookies and tracking pixels (the internet's favourite stalkers)
Sometimes, email addresses for "verification" (spoiler: it's for marketing)
And the worst part? They still can't prevent spam bots from flooding their forms with garbage data. So you get the worst of both worlds: your privacy is violated AND the data quality is terrible.
I wanted to build something different. Something that could prove you're a real human without knowing who you are.
Sounds impossible, right? That's where Self Protocol comes in.
The breakthrough came when I discovered Self Protocol and their approach to identity verification. Instead of collecting personal data, Self uses zero-knowledge proofs backed by NFC passport scanning.
Here's the beautiful part: you can prove you're:
A real human being
Over 18 years old
Not on OFAC sanctions lists
A unique individual (preventing duplicate submissions)
And much more, if you follow along with the SDK
All without revealing your name, nationality, passport number, or any other personally identifiable information. The form owner just gets a cryptographic hash that proves you're legit, nothing more, nothing less.
That's when I decided to build a form builder that puts this technology front and center.
What if every form submission required verification, but collected zero personal data beyond what the form explicitly asks for?
No tracking. No fingerprinting. No backdoor data collection. Just verified, authentic responses from real humans.
Here's what I built:

The form builder lives at the root of the application (/). I wanted something clean and intuitive, think Notion-style sidebar with a drag-and-drop-ish feel.
I built a responsive layout that adapts beautifully:

This is where you design your forms. I kept it deliberately simple:
// Form structure - clean and minimal.
interface FormField {
id: string; // Auto-generated random ID.
label: string; // "What's your name?".
type: FieldType; // text | textarea | number.
required: boolean; // Make it mandatory or not.
}
The builder lets you:
Add a title (which auto-generates a URL-friendly slug)
Write an optional description
Add unlimited fields with three types: Short text, Long text, or Number
Mark fields as required or optional
Reorder fields by dragging (okay, I haven't built that yet, but it's on the list!)
When you hit "Save form," here's what happens:
// Client sends the form data.
const response = await fetch(`${API_BASE_URL}/api/forms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: "Customer Feedback",
slug: "customer-feedback", // Auto-generated from title.
description: "Tell us what you think!",
fields: [
{ id: "abc123", label: "Your Name", type: "text", required: true },
{ id: "def456", label: "Feedback", type: "textarea", required: true }
]
})
});
The server validates it and stores everything in SQLite:
// server/modules/forms/form.service.mjs.
export function createFormFromRequest(payload) {
validateFormPayload(payload); // Ensures title, slug, and fields exist.
const config = {
fields: payload.fields
};
return createForm({
title: payload.title,
description: payload.description || "",
slug: payload.slug,
theme: "light",
config
});
}

This shows you exactly what your form will look like to end users. It's rendered using the same components as the public submission page, so what you see is genuinely what you get.
No surprises. No "it looks different in production" moments.

This is where you see submissions. For each response, you get:
Timestamp of submission
A verified user identifier (looks like 0xf4e9c2b1a8d7...)
All the answers in a clean grid layout
Here's the thing though: that user identifier is a cryptographic hash, not a real identity. You can't trace it back to a person. But it's consistent—so if someone fills out multiple forms, you can see it's the same verified human without knowing who they are.
Now here's where it gets interesting. When you share a form (like oursite.com/forms/customer-feedback), users go through a multi-step verification flow.
When someone visits your form URL, the page fetches the form definition:
// client/src/app/forms/[slug]/page.tsx.
const loadForm = async () => {
const res = await fetch(`${API_BASE_URL}/api/forms/${slug}`);
const data = await res.json();
setForm(data.form);
setStep("verify"); // Move to verification step.
};
Nothing special yet, just a normal API call.

Scan this QR code with the Self app to prove you're human
Here's how I initialise the Self verification:
// Build the Self app configuration.
const app = new SelfAppBuilder({
version: 2,
appName: "Self Forms Verification",
scope: "demo-scope", // Must match backend.
endpoint: "https://[your-ngrok].ngrok-free.app/api/verify",
userId: ethers.ZeroAddress, // Anonymous by default.
endpointType: "staging_https",
userDefinedData: `form:customer-feedback`, // Embeds form context.
disclosures: {
minimumAge: 18, // Require 18+ verification.
excludedCountries: [],
ofac: true, // Check OFAC sanctions.
nationality: false, // Don't disclose nationality.
gender: false // Don't disclose gender.
}
}).build();
// Render the QR code.
<SelfQRcodeWrapper
selfApp={selfApp}
onSuccess={handleSuccessfulVerification}
onError={handleVerificationError}
type="websocket"
size={230}
/>
Notice the disclosures object? This is where you specify what you want to verify without actually collecting that data. I'm asking for:
Age verification (must be 18+)
OFAC sanctions check (ensures they're not on government watchlists)

When a user scans the QR code with their Self mobile app:
Self app reads the QR and sees the verification requirements
User authenticates with biometrics (Face ID, fingerprint, etc.)
Self app generates a zero-knowledge proof using NFC-scanned passport data
Proof is sent to your backend at /api/verify
Backend validates the proof cryptographically
Frontend unlocks the form if verification succeeds
The beautiful part? At no point does the backend see their passport data. Self Protocol's zero-knowledge proof system ensures that.
The server is where I spent the most time getting things right. I wanted it to be modular, secure, and easy to understand.
This is the heart of the system. When the Self app sends a proof, my backend needs to validate it:
// server/index.mjs.
const selfBackendVerifier = new SelfBackendVerifier(
scope, // "demo-scope".
endpoint, // Public ngrok URL.
true, // mockPassport = true (for testing).
AllIds, // Accept all ID types.
new DefaultConfigStore({
minimumAge: 18,
excludedCountries: [],
ofac: true // OFAC sanctions check enabled.
}),
"hex" // User ID format.
);
app.post("/api/verify", async (req, res) => {
const { attestationId, proof, publicSignals, userContextData } = req.body;
// Verify the zero-knowledge proof.
const result = await selfBackendVerifier.verify(
attestationId,
proof,
publicSignals,
userContextData
);
const { isValid, isMinimumAgeValid, isOfacValid } = result.isValidDetails;
// Reject if any check fails.
if (!isValid || !isMinimumAgeValid || isOfacValid) {
let reason = "Verification failed";
if (!isMinimumAgeValid) {
reason = "Minimum age verification failed";
} else if (isOfacValid) {
reason = "User is on OFAC sanctions list";
}
return res.status(200).json({
status: "error",
result: false,
reason
});
}
// Store verification result for frontend to retrieve.
lastVerificationResult = result;
return res.status(200).json({
status: "success",
result: true,
credentialSubject: result.discloseOutput,
userData: result.userData
});
});
This code does three critical checks:
Proof validity - Is the cryptographic proof mathematically sound?
Age verification - Is the user 18 or older based on their passport?
OFAC sanctions - Is this person on government watchlists?
If all checks pass, the verification succeeds. The frontend polls a debug endpoint to retrieve the result:
app.get("/debug/last-result", (_req, res) => {
if (!lastVerificationResult) {
return res.status(200).json({ status: "empty" });
}
res.status(200).json({
status: "ok",
verificationResult: lastVerificationResult
});
});
You might be wondering: "Why store lastVerificationResult in a global variable instead of the database?"
Honest answer: simplicity for the MVP.
In a production system with multiple server instances, you'd want to use Redis or store it in the database with a short TTL. But for a starter template meant to be forked and extended, keeping it simple makes the code easier to understand.
I wanted the backend to be easy to navigate, so I organized it into layers:

The repository handles all database operations:
// server/modules/forms/form.repository.mjs.
export function createResponse({ formId, answers, selfVerification }) {
const stmt = db.prepare(`
INSERT INTO form_responses
(form_id, answers_json, self_verification_json)
VALUES (?, ?, ?)
`);
const info = stmt.run(
formId,
JSON.stringify(answers),
JSON.stringify(selfVerification)
);
return info.lastInsertRowid;
}
The service layer validates data and enforces rules:
// server/modules/forms/form.service.mjs.
export function createFormResponse(slug, payload) {
const form = getFormBySlug(slug);
if (!form) {
throw new Error("Form not found");
}
// CRITICAL: Enforce Self verification requirement.
if (!payload.selfVerification?.userIdentifier) {
throw new Error(
"selfVerification with userIdentifier required. " +
"Make sure the user completes Self verification before submitting."
);
}
return createResponse({
formId: form.id,
answers: payload.answers,
selfVerification: payload.selfVerification
});
}
This is the key security check: every form submission MUST include valid Self verification data. No verification? No submission.
Controllers are thin wrappers that call service functions:
// server/modules/forms/form.controller.mjs.
export async function createResponseHandler(req, res, next) {
try {
const { slug } = req.params;
const response = createFormResponse(slug, req.body);
res.status(201).json({ response });
} catch (error) {
next(error);
}
}
And routes define the API endpoints:
// server/modules/forms/form.routes.mjs.
router.post("/:slug/responses", createResponseHandler);
This separation makes the code easy to test, extend, and understand. Want to add email notifications? Add it to the service layer. Want to change how errors are handled? Modify the controller.

Let's compare traditional form solutions with Self-verified forms:
| Aspect | Traditional Forms | Self-Verified Forms |
| Bot Prevention | CAPTCHA (annoying, often broken) | Cryptographic proof of humanity |
| Privacy | Collects IP, fingerprints, cookies | Zero tracking beyond form fields |
| Anonymous Claims | "Trust us, it's anonymous 😉" | Cryptographically anonymous |
| Age Verification | "Just check this box you're 18+" | NFC passport-based proof |
| Duplicate Prevention | Email/IP blocking (easy to bypass) | Unique cryptographic identifier |
| Compliance | Manual KYC (invasive) | Automatic OFAC checks without PII |
| Data Quality | High spam rate | Verified humans only |
This approach isn't just theoretical. Here's where Self-verified forms shine:
1. Anonymous Whistleblowing
Employees can report workplace issues
Verified as real employees without revealing identity
Company knows reports are from verified humans, not spam
2. Sensitive Health Surveys
Research surveys about personal health topics
Age verification ensures legal compliance
No PII collected beyond survey answers
3. Feedback Forms for Regulated Industries
Financial services customer feedback
OFAC compliance built-in
Genuine customer responses only
4. Community Voting & Polls
One person, one vote (cryptographically enforced)
No duplicate submissions possible
Completely anonymous but verified
5. Bug Bounty Submissions
Verify submitter is real person
Prevent spam submissions
Maintain researcher anonymity if desired
6. Product Beta Signups
Ensure real humans join beta programs
Prevent bot signups
No email required (optional)
Want to build on this? Here's how to get started:
# Clone the repo.
git clone https://github.com/kartikmehta8/self-truely-anon-forms
cd self-truely-anon-forms
# Install backend dependencies.
cd server
npm install
# Install frontend dependencies.
cd ../client
npm install
# Backend .env.
cd server
cp .env.example .env
# Edit: SELF_SCOPE, SELF_ENDPOINT.
# Frontend .env.
cd ../client
cp .env.example .env.local
# Edit: Match backend values.
# Terminal 1: Start backend.
cd server
npm run dev
# Terminal 2: Start ngrok.
ngrok http 3001
# Copy the https URL.
# Update .env files with ngrok URL.
# Terminal 3: Start frontend.
cd client
npm run dev
Visit http://localhost:3000 and start building forms!
Building this project changed how I think about online forms. We don't have to choose between security and privacy. We don't have to spy on users to verify they're real.
Zero-knowledge proofs aren't just academic curiosities, they're practical tools that developers can use today to build better, more respectful applications.
And honestly? That's pretty exciting.
If you ever need help or just want to chat, DM me on Twitter / X or LinkedIn.