# Building Forms That Actually Respect Your Privacy

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. <mark>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.</mark>

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](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: <mark>your privacy is violated AND the data quality is terrible.</mark>

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.

[![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763498080333/bbcc8e19-1a8b-46a8-987a-b64ecfc738ef.png align="center")](https://self.xyz)

## What If Forms Could Verify Humans Without Violating Privacy?

The breakthrough came when I discovered [Self Protocol](https://self.xyz) 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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763531103888/ee8bd2a1-c241-405c-9cd5-4b60ec20b8ab.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763532011365/430298ea-7f92-4026-9e29-f94907adcd49.png align="center")

#### 1\. Form Builder Tab

This is where you design your forms. I kept it deliberately simple:

```typescript
// 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:

```typescript
// 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:

```javascript
// 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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763532254399/bd2e8e5e-19e9-4c32-b33d-5242141bd93f.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763532365345/369e1846-bef1-4451-985a-49781b6c637a.png align="center")

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—<mark>so if someone fills out multiple forms, you can see it's the same verified human without knowing who they are.</mark>

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

```typescript
// 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)

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763532882116/f3168647-c12c-4be9-8feb-caf77911357e.png align="center")

*Scan this QR code with the Self app to prove you're human*

Here's how I initialise the Self verification:

```typescript
// 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)
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763533118781/472992c5-47f4-4eba-a7ef-f0f367c97727.png align="center")

### 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? <mark>At no point does the backend see their passport data. Self Protocol's zero-knowledge proof system ensures that.</mark>

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

```javascript
// 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:

```javascript
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763533558831/559148b6-2f84-47f8-8e58-7f6900c655f1.png align="center")

### Repository Layer: Raw Data Access

The repository handles all database operations:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1763533826611/fc58b152-ad92-4996-b77d-2a9d549ce614.png align="center")

## Why This Matters?

### The Problem with Traditional Forms

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 |

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

%[https://www.loom.com/share/7aeb2e1b36ae49b08fb20894a61969ef] 

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

### Installation

```bash
# 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

```bash
# 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

```bash
# 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`](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.

<mark>Zero-knowledge proofs aren't just academic curiosities, they're practical tools that developers can use today to build better, more respectful applications.</mark>

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**](https://www.mrmehta.in/)

[***X***](https://x.com/kartik_mehta8) */* [***LinkedIn***](https://www.linkedin.com/in/kartikmehta17)
