Skip to main content

Command Palette

Search for a command to run...

Building Forms That Actually Respect Your Privacy

Updated
11 min read
Building Forms That Actually Respect Your Privacy
K

A code-dependent life form.

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

The "Anonymous" Lie We've All Been Sold

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.

What If Forms Could Verify Humans Without Violating Privacy?

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.

The “Self Forms” Concept

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:

Part 1: Building the Form Builder

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.

The Interface: Sidebar + Three Tabs

I built a responsive layout that adapts beautifully:

1. Form Builder Tab

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
  });
}

2. Live Preview Tab

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.

3. Responses Tab

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.

Part 2: The Public Form Experience

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.

Step 1: Loading the Form

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.

Step 2: Self Verification (The Cool Part)

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)

What Happens Behind the Scenes

When a user scans the QR code with their Self mobile app:

  1. Self app reads the QR and sees the verification requirements

  2. User authenticates with biometrics (Face ID, fingerprint, etc.)

  3. Self app generates a zero-knowledge proof using NFC-scanned passport data

  4. Proof is sent to your backend at /api/verify

  5. Backend validates the proof cryptographically

  6. 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.

Part 3: The Backend

The server is where I spent the most time getting things right. I wanted it to be modular, secure, and easy to understand.

The Self Verification Endpoint

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:

  1. Proof validity - Is the cryptographic proof mathematically sound?

  2. Age verification - Is the user 18 or older based on their passport?

  3. 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
  });
});

Why Store in Memory Instead of Database?

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.

Part 4: Server Architecture

I wanted the backend to be easy to navigate, so I organized it into layers:

Repository Layer: Raw Data Access

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;
}

Service Layer: Business Logic

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.

Controller & Routes: Express Wrappers

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.

The Complete Flow

Why This Matters?

The Problem with Traditional Forms

Let's compare traditional form solutions with Self-verified forms:

AspectTraditional FormsSelf-Verified Forms
Bot PreventionCAPTCHA (annoying, often broken)Cryptographic proof of humanity
PrivacyCollects IP, fingerprints, cookiesZero 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 PreventionEmail/IP blocking (easy to bypass)Unique cryptographic identifier
ComplianceManual KYC (invasive)Automatic OFAC checks without PII
Data QualityHigh spam rateVerified humans only

Real-World Use Cases

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)

Getting Started: Your Own Self-Verified Forms

Want to build on this? Here's how to get started:

Installation

# 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

Configuration

# 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.

Running

# 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!

Final Thoughts

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.

Kartik Mehta

X / LinkedIn

More from this blog

K

Kartik Mehta

36 posts

Engineering and Product Development harmoniously united.