dhaga.blog
Engineering

DPDP consent on a signup form: what the law asks, what we built

By Anchit Shrivastava ยท

What India's DPDP Act asks of a signup checkbox, how GDPR and LGPD compare, and how we built a separate, recorded consent box, including what it cannot prove.

The short version

A signup checkbox that says "I agree" is not a consent record. India's Digital Personal Data Protection Act, 2023 makes the company that asked responsible for proving that notice was given and consent was obtained, and GDPR, UK GDPR and Brazil's LGPD say much the same in their own words. So the work is not the box. It is what you can still show a year later: a separate, unticked box; a stamp written by the server rather than the browser; and a version that says which text the person agreed to.

We have just shipped that on Dhaga's signup page, and this post is the design and the limits. We are not claiming Dhaga is compliant with the DPDP Act or any other law. The Privacy Notice the box points at is still missing things a regulator would expect, and the last sections say which.

Not legal advice

We are engineers, not lawyers. What follows is how we read the statute and what we chose to build. The dates and rules are a snapshot, and some of them are still moving. Check the current text of the Act and the Rules, and ask counsel, before you rely on any of it for your own product.

The rest of this post is the deep dive: what the DPDP Act asks of consent, how the same checkbox reads under four other regimes, the design we shipped, and an honest list of what the record cannot prove. File paths refer to the real code.


Three terms first, because the Act uses them everywhere. The Data Fiduciary is whoever decides the purpose and means of processing: you, the company running the signup. The Data Principal is the individual the data is about: your user (for a child, the definition includes the parent or lawful guardian). A Data Processor is anyone processing on the fiduciary's behalf, which in practice means your host, your email sender and your model vendor.

Consent is one of two grounds for processing (section 4). The other is a short list of "certain legitimate uses" in section 7, mostly about the State, legal obligations, emergencies and employment. One clause, 7(a), covers data a person "voluntarily provided" for a specified purpose without indicating that they do not consent. There is no direct counterpart to GDPR's "necessary for a contract" or "legitimate interests". Whether a given signup field can rest on 7(a) is a question for your lawyer. Our box asks for consent explicitly, which is a choice with a cost, and we come back to it under withdrawal.

What section 6 and its neighbours ask of the consent itself:

  1. The quality bar (s.6(1)). Consent must be "free, specific, informed, unconditional and unambiguous with a clear affirmative action", for a specified purpose, and limited to the personal data necessary for that purpose. "Unconditional" is the word that catches bundled boxes.
  2. A notice with the request (s.5, s.6(3)). Every request for consent is accompanied or preceded by a notice saying what data is processed and why, how the person can withdraw and use their grievance right, and how to complain to the Data Protection Board. It has to be in clear, plain language, with the option to read it in English or any language in the Eighth Schedule. The Rules add detail: an itemised description of the data, the purpose and the service, "understandable independently of any other information", and a link through which the person can withdraw consent, exercise their rights and complain (Rule 3).
  3. Withdrawal (s.6(4) to s.6(6)). The person can withdraw "at any time, with the ease of doing so being comparable to the ease with which such consent was given". Withdrawal is not retroactive, and after it the fiduciary must stop processing within a reasonable time, unless a law requires or allows it to continue.
  4. The burden of proof (s.6(10)). If the question ever comes up, the fiduciary must prove that a notice was given and that consent was given in accordance with the Act. This is the sentence that turns a checkbox into an engineering problem.
  5. Consent Managers (s.6(7) to s.6(9)). A person may give, manage, review or withdraw consent through a registered Consent Manager, which acts on their behalf. Registration is regulated by Rule 4.
  6. Children (s.9). A child is anyone under eighteen. Before processing a child's data, the fiduciary must get verifiable consent from a parent, and must not track or behaviourally monitor children or direct targeted advertising at them. Rule 10 says how a fiduciary checks that the person claiming to be the parent is an adult: existing records, details the person volunteers, or a token from a government-authorised entity or a DigiLocker provider. Some exemptions are left to be prescribed, so check the current Rules for them.
  7. Grievance redressal (s.13). Readily available means of raising a grievance with the fiduciary, answered within a prescribed period, which a person must exhaust before going to the Board.

When it bites

The Rules were notified in November 2025 and commence in phases from the date of publication. A first group of rules took effect straight away. Rule 4, on Consent Manager registration, commences one year after publication. Rules 3 and 5 to 16 (which include the notice rule and the children's rule) and Rules 22 and 23 commence eighteen months after. Counted from November 2025, that is about November 2026 and about May 2027.

A proposal to shorten the window for large companies was reported in January 2026. We could not find a gazetted change, but that is exactly the kind of thing to check against the current Rules rather than trust any blog post, this one included. We could also only read the Rules through third-party reproductions, as the official copies would not load for us, so treat the rule numbers above as pointers to check, not citations to quote.

The same checkbox under four other regimes

The regimes differ in a hundred ways. For one signup checkbox they converge.

RegimeThe rule that touches your checkboxIn UI terms
India, DPDP Acts.6(1) free, specific, informed, unconditional, unambiguous, clear affirmative action; s.6(10) the fiduciary proves notice and consentA deliberate act, and a record you can produce
EU, GDPRArt. 7(1) be able to demonstrate consent; 7(2) the request is clearly distinguishable from other matters; 7(3) as easy to withdraw as to give; 7(4) tying a service to unnecessary processing counts against "freely given"; Recital 32 and the CJEU's Planet49 judgment (2019) rule out pre-ticked boxesUnticked, not folded into the Terms, recorded
UK GDPRCarries the same Article 7 wordingSame as the EU
Brazil, LGPDArt. 8: written consent sits in a clause highlighted from the other contractual clauses; generic authorisations are void; the controller bears the burden of proof; revocation through a free and facilitated procedureHighlighted, specific, easy to revoke
California, CCPA and CPRAMostly an opt-out regime for sale and sharing, so a signup box is not its centre of gravity; but where consent is required, an agreement obtained through dark patterns does not count as consentNo nudging, symmetric choices

Strip the differences and four properties survive: unbundled (its own box), unticked (the person acts), recorded (you can show it) and versioned (you can show what they agreed to). That is what we built. We also did not geo-gate: every visitor sees the same two boxes, because a wrong guess about where someone is leaves no consent record for the person it mattered for.

One caution that applies to the whole table. Consent is not always the right basis, and asking for it is not free. European regulators' guidance on consent (the EDPB's Guidelines 05/2020) warns against asking for consent that you could not honour a "no" to, and against swapping to another basis after the fact. If processing would carry on whatever the person ticked, the box is misleading. Decide purpose by purpose what genuinely needs consent, and take advice on it.

What we built

Dhaga Cloud's /signup has two checkboxes. Everything below is hosted-only: a single-tenant deployment shows no boxes, demands no header and leaves the columns empty.

Two boxes, not one

The Terms box ("I agree to the Terms of service") already existed and records acceptance of a contract. Consent to processing personal data is a different act, so it is a second box beneath it: "I consent to Dhaga processing my personal data as described in the Privacy Notice", with the notice linked. The Terms line no longer mentions the privacy page at all, because bundling the two would make the consent weaker, not stronger.

Both share one row component (components/app/auth/ConsentCheckbox.tsx) and differ only in their words (TermsAcceptance.tsx, PrivacyConsent.tsx). The privacy box is unticked by default and has its own state in the signup hook. It is never derived from the Terms state, so ticking one cannot tick the other.

Pressing sign up with a box still empty does not throw an error. It moves focus to the first outstanding box, rings it in the accent colour and says "One last step: tick both boxes above and you're in." That is a step not yet taken, not a fault, and a form that scolds is closer to the dark-pattern line than one that points.

One gate for every way in

The email form and every OAuth button (Google, Microsoft and the rest) share a single flag:

const termsBlocked = requireTermsAcceptance && !(termsAccepted && privacyAccepted);

An unticked box that can be walked around with one click records nothing, so the OAuth buttons are held closed by the same flag. This is the same lesson as our post on why a login page is a signup form: control the doors, not the form. It is also why magic-link signup is gone. An account created from the login page would skip the box entirely.

The server writes the record

The stamp is written in better-auth's user.create.before hook, not in the form's submit handler, because the form is only one of the ways an account comes into being:

// lib/auth/config/signup-hooks/before-create.ts (trimmed)
const affirmed = consentAckFromHeaders(context?.headers); // verified, signed, current
const now = new Date();
return {
  data: {
    ...user,
    termsAcceptedAt: affirmed.has("terms") ? now : null,
    termsVersion: affirmed.has("terms") ? TERMS_VERSION : null,
    // Its own consent, its own edition.
    privacyConsentAt: affirmed.has("privacy") ? now : null,
    privacyNoticeVersion: affirmed.has("privacy") ? PRIVACY_NOTICE_VERSION : null,
  },
};

The two new fields are declared with input: false in the auth config, so a client cannot post them, and the hook spreads the incoming user first and the stamps last (a null when nothing was verified), so a value that did sneak into the payload is overwritten. A unit test posts a 1970 timestamp and a 1970 version in the payload and asserts that neither survives. The reasoning is the one behind the whole post: a consent record the client can write is an assertion, and an assertion nobody can check is worse than having no record.

Versioned, and loud when stale

PRIVACY_NOTICE_VERSION is a sortable date string (2026-09-23) in utils/constants/legal.ts. The /privacy page takes its "updated" date from a companion constant in the same file, and the two are bumped together whenever the notice changes materially. That is a manual pairing, so it is a convention rather than a guarantee. Sortable on purpose: the only useful question later is "did this person consent to something older than X?", and with dates that is a string comparison.

Before it creates an account the form asks our server to record that the boxes are ticked (POST /api/signup/ack), and gets back a short-lived signed cookie bound to the current notice edition. The hook stamps a timestamp only when that cookie verifies. A hosted email signup without a valid one is refused; an account created some other way is still created, but with no stamp:

if (context?.path === "/sign-up/email" && !SIGNUP_AFFIRMATIONS.every((a) => affirmed.has(a))) {
  throw new APIError("BAD_REQUEST", { message: SIGNUP_ACK_REQUIRED_MESSAGE });
}

The point is to fail loudly. A tab opened before we revise the notice cannot quietly record consent to wording the person never saw: the cookie names the edition, so bumping the constant makes every stale one stop verifying.

What gets stored

Two nullable columns on the account row, added with ADD COLUMN IF NOT EXISTS, no default, nothing rewritten. They are a separate chunk appended last in our idempotent DDL chain, for the reason in our post on the self-healing schema: widening an earlier chunk would re-hash it, and everything after it, on every deployed database.

-- Who consented to a notice edition older than the current one?
SELECT id, privacy_notice_version, privacy_consent_at
FROM "user"
WHERE privacy_notice_version < '2026-09-23';

-- Who has no recorded privacy consent at all?
SELECT count(*) FROM "user" WHERE privacy_consent_at IS NULL;

Existing accounts are not backfilled. Anyone who signed up before the box existed did not tick it, and inventing a timestamp for them would destroy the only reason to keep the record. The second query is how you find them.

What the record cannot prove

This is the section we would want to read before trusting someone else's consent log.

  • The acknowledgement endpoint is public. Anyone can call it with the three boxes set to true and get a valid cookie, so the record proves our server was asked, not that a human ticked. The check stops a stale tab and a client that skipped the form from being recorded at all. It does not stop a determined caller, and we do not pretend it does.
  • OAuth carries the same cookie. The form sets it just before the redirect to the provider, and the browser sends it back on the provider's callback, which is where the account is created. An OAuth account created without it (a script, a flow that skipped the form) is still created, but its stamps are null: no record, rather than a record that says something no one confirmed.
  • Existing users are not asked again. There is no re-consent flow. Their columns stay null until we build one.
  • The checkbox wording is not versioned on its own. The version names the Privacy Notice edition. Nothing in the code ties the label's wording to it, so rewording the label means remembering to bump the constant.
  • Verification so far is partial. The server hook is unit-tested, including the forged-payload, stale-edition and non-form cases. The browser-level end-to-end spec was updated with the change but, at the time of writing, has not been run.

What is still open around it

A consent box is only as informed as the notice behind it, and the notice is the unfinished part. Our own audit found that the Privacy Notice still lacks several things a regulator would expect: retention periods, named sub-processors and cross-border transfer, a named grievance contact or officer, how to withdraw consent, how to complain to a regulator, and a statement about children and a minimum age. (Part of this already exists elsewhere: our trust page names each sub-processor with its region. But the notice itself describes them only by role, and a notice has to be understandable on its own.) Consent to a notice with those gaps is weaker than the checkbox makes it look, so expanding it is the next job. Until then, the honest summary is that we built the mechanism and the notice is still catching up.

Three more gaps, each connected to the law above:

  • Withdrawal is not as easy as giving. Ticking takes one click. Today, withdrawing means deleting the account (Settings, then Account, then the Danger zone, with a typed confirmation), which erases the account and its graph in one transaction, and the full export (contacts CSV, vCard, a JSON dump) works before you go. There is no "withdraw privacy consent but stay" control, and the notice does not yet say how to withdraw. For a product that cannot work without processing what you give it, that may be the honest shape of consent to the service, but it does not meet "comparable ease" on its face.
  • Children. We do not collect an age, do not ask for parental consent and do not yet state a minimum age. DPDP's definition of a child is anyone under eighteen, and the verification rules are more demanding than a date-of-birth field.
  • Consent Managers and grievance officers. We do not integrate with any Consent Manager, and there is a contact address for questions but no named grievance officer.

A checklist you can copy

SIGNUP CONSENT CHECKLIST (not legal advice)

Design
[ ] Consent has its own checkbox. Never folded into "I agree to the Terms".
[ ] Unticked by default. No pre-ticked box, no "by continuing you agree".
[ ] The label says what is consented to, in plain language, and links to
    the notice.
[ ] One gate for every way in: email form, OAuth buttons, SSO, magic links.
[ ] An unticked box prompts ("one last step"); it does not scold.
[ ] The service is not conditional on consent to processing it does not
    need. Optional purposes get their own optional boxes.

Record
[ ] The server writes the record. Client-posted values are ignored.
[ ] Store when, and which edition of the notice (sortable: 2026-09-23).
[ ] Keep the text of every notice edition you have ever shown.
[ ] Version the checkbox wording too. Rewording it bumps the version.
[ ] Refuse a signup that presents a stale edition, loudly.
[ ] Do not backfill consent for accounts that never gave it.
[ ] Write down what your record cannot prove (forgeable headers, OAuth).

Notice
[ ] Itemised: what data, what purpose, what service, in plain language.
[ ] Available in the languages your users read (DPDP: English or any
    Eighth Schedule language).
[ ] Says how to withdraw, how to exercise rights and how to complain,
    to you first and then to the regulator.
[ ] Covers retention, sub-processors, cross-border transfer, a grievance
    contact.

After signup
[ ] Withdrawal is findable and about as easy as giving.
[ ] Export and deletion exist, and deletion cascades to derived data.
[ ] Decide your minimum age, say it, and know what you do for younger users.
[ ] Re-check the current Rules, not this checklist, before you rely on it.

The takeaways

  1. Build the record, not the box. The law's question is "can you prove it?". Who, when, which text and which path is the answer, and the checkbox is only how you collect it.
  2. Unbundle, untick, record, version. Four properties that satisfy every regime we checked for a signup box, and cost one extra row and two columns.
  3. Let the server write it, and put the gate on every door. A consent record the browser can write, or an OAuth button that walks around the box, makes the rest worthless.
  4. Fail loudly on a stale edition. A noisy refusal is cheaper than a quiet record of consent to text nobody saw.
  5. Say what it does not prove. A forgeable header and an OAuth path covered only by the UI are fine to ship if you write them down, and not fine to hide.

The notice behind the box, withdrawal that is as easy as giving, and a position on children are the longer work, and we would rather publish that list than imply it is finished. The notice is at /privacy and the contract is at /terms. The same instinct, that nothing derived from your data should outlive it, runs through where did you get that, on receipts and cascading deletion.

Share

Discussion

On this page