GDPR Email Consent and Double Opt-In: The Developer's Compliance Checklist

hangrydev ·

One Missing Database Column Can Cost You €20 Million

Not because your feature is broken. Because your consent record doesn’t exist.

GDPR’s fines for consent violations reach €20 million or 4% of global annual turnover, whichever is higher. The Berlin data protection authority fined Deutsche Wohnen SE €14.5 million in 2019 for failing to implement a compliant data retention system. Not for spamming. For storing personal data indefinitely without being able to demonstrate a legal basis for keeping it.

That’s a schema problem. And it’s one you can fix before you ship.

This guide skips the legal theory. It covers the database fields you need, the confirmation flow that creates an audit trail, where email validation fits in, and how to handle re-consent after a policy change.

What GDPR Actually Requires From Developers

GDPR Article 7 requires that you can demonstrate consent was given. Article 6 requires a lawful basis for processing. Recital 32 specifies that consent must be “a clear affirmative act.”

Three things that matter in practice:

You need proof. “We had a checkbox on the form” isn’t enough. You need a record of when the user consented, what they were shown, and what they agreed to.

Consent must be specific. A single checkbox that covers “marketing emails, analytics tracking, and third-party data sharing” won’t hold up. Each purpose needs its own consent.

Withdrawal must be easy. If it takes more clicks to unsubscribe than it did to subscribe, you’re already in violation territory.

Double opt-in (DOI) isn’t explicitly required by GDPR, but it solves two problems at once: it provides strong proof that a real person owned the email address at the time of signup, and it gives you a timestamped confirmation event to store. German courts have gone further. The Bundesgerichtshof (BGH) ruled in 2011 (I ZR 164/09) that single opt-in is insufficient for email marketing because it can’t rule out third-party misuse. DOI is effectively required in Germany because it’s the only reliable way to prove the subscriber consented rather than having their address submitted without their knowledge.

Your consent record needs these fields. Add them now, before you need them in a legal dispute.

CREATE TABLE email_consents (
  id              BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  email           TEXT NOT NULL,
  user_id         BIGINT REFERENCES users(id) ON DELETE SET NULL,

  -- What the user agreed to
  consent_version TEXT NOT NULL,      -- "v1.2" or a hash of the policy text
  consent_text    TEXT NOT NULL,      -- Exact wording shown at collection point

  -- When and where
  ip_address      INET NOT NULL,
  user_agent      TEXT,
  collected_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  -- Double opt-in confirmation
  doi_token       TEXT UNIQUE,
  doi_confirmed_at TIMESTAMPTZ,
  doi_ip_address  INET,

  -- Withdrawal
  withdrawn_at    TIMESTAMPTZ,
  withdrawal_reason TEXT,

  -- Audit
  source          TEXT NOT NULL,      -- "signup_form", "checkout", "referral"
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX ON email_consents (email);
CREATE INDEX ON email_consents (doi_token) WHERE doi_token IS NOT NULL;
CREATE INDEX ON email_consents (user_id) WHERE user_id IS NOT NULL;

In Rails, this maps to a model with a few responsibilities:

class EmailConsent < ApplicationRecord
  belongs_to :user, optional: true

  scope :confirmed, -> { where.not(doi_confirmed_at: nil) }
  scope :active, -> { confirmed.where(withdrawn_at: nil) }

  def confirmed?
    doi_confirmed_at.present?
  end

  def active?
    confirmed? && withdrawn_at.nil?
  end

  def confirm!(ip_address:)
    update!(
      doi_confirmed_at: Time.current,
      doi_ip_address: ip_address
    )
  end

  def withdraw!(reason: nil)
    update!(
      withdrawn_at: Time.current,
      withdrawal_reason: reason
    )
  end
end

The consent_text field is the one that actually defends you. Store the exact sentence the user saw: “I agree to receive marketing emails from Acme Inc. I can unsubscribe at any time.” If you later need to prove what the user consented to, you have it verbatim, not a vague reference to “our marketing consent checkbox.”

Validate the Email Before Sending the DOI Email

This is the step most developers get wrong.

The flow they build: user submits form, create consent record, send confirmation email. The flow they should build: user submits form, validate email, create consent record if valid, send confirmation email.

Why does this matter? Your DOI email is a transactional message, sent from your primary sending domain. Bounces on transactional email hurt your sender reputation the same way marketing bounces do. If you send DOI emails to invalid addresses (mistyped, nonexistent, or already defunct), you’re burning your transactional domain on mail that can never be confirmed anyway.

Validate before you send. Check syntax, verify the MX record exists, and reject known disposable email addresses. See the email validation API guide for the full breakdown of what each check catches.

class SubscriptionController < ApplicationController
  def create
    email = params[:email].to_s.strip.downcase

    # Validate before touching the database or sending anything
    validation = TruemailClient.validate(email)

    unless validation.valid?
      return render json: {
        error: validation_error_message(validation)
      }, status: :unprocessable_entity
    end

    # Don't let disposable addresses through
    if validation.disposable?
      return render json: {
        error: "Please use a permanent email address."
      }, status: :unprocessable_entity
    end

    consent = EmailConsent.create!(
      email: email,
      consent_version: ConsentText::CURRENT_VERSION,
      consent_text: ConsentText::MARKETING_COPY,
      ip_address: request.remote_ip,
      user_agent: request.user_agent,
      doi_token: SecureRandom.urlsafe_base64(32),
      source: "signup_form"
    )

    ConsentConfirmationMailer.confirm(consent).deliver_later

    render json: { message: "Check your inbox to confirm your subscription." }
  end

  private

  def validation_error_message(validation)
    return "Email address doesn't exist." if validation.mx_invalid?
    "Email address format isn't valid."
  end
end

The validation call happens before EmailConsent.create!. No database record, no email send, if the address fails.

The DOI Confirmation Flow

The confirmation endpoint receives the token, validates it, and records the confirmation event.

class ConsentConfirmationsController < ApplicationController
  def show
    consent = EmailConsent.find_by(doi_token: params[:token])

    if consent.nil?
      return render :invalid_token, status: :not_found
    end

    if consent.confirmed?
      return redirect_to confirmed_path, notice: "You're already confirmed."
    end

    if consent.created_at < 72.hours.ago
      return render :expired_token, status: :gone
    end

    consent.confirm!(ip_address: request.remote_ip)

    redirect_to confirmed_path, notice: "Subscription confirmed. Welcome."
  end
end

Three cases to handle: invalid token, already confirmed, and expired token. Expired tokens are a source of support tickets if you don’t handle them gracefully. Show a “request a new confirmation email” link rather than a dead error page.

The 72-hour window is a convention, not a legal requirement. Some teams use 24 hours to minimize the window of an unconfirmed record sitting in the database. Choose based on your support volume and how often your users check email. For the Django implementation or Next.js implementation, the same logic applies with framework-specific routing.

What to Send in the Confirmation Email

Keep it short. One button. No marketing copy.

The confirmation email is transactional, not promotional. Its only job is to get the click. Adding product features, testimonials, or a discount code to the DOI email doesn’t just dilute the message. It muddies the consent record. If someone clicks because of the discount rather than understanding what they’re confirming, the quality of that consent is already questionable.

Subject: “Confirm your subscription to [Brand] emails”

Body: Two sentences maximum. “You (or someone using this address) signed up to receive emails from [Brand]. Click the button below to confirm. If you didn’t sign up, ignore this email and nothing will happen.”

One button: “Confirm my subscription”

Footer: “This confirmation link expires in 72 hours.”

That’s it. Don’t add anything else.

Your terms change. You add a new email type. You merge consent with a partner program. Any material change to what you said in consent_text requires new consent from existing subscribers.

The decision tree is simpler than it sounds:

  1. Did your email program change in a way that affects what subscribers agreed to? Get new consent.
  2. Did only formatting or minor wording change? No new consent required, but update consent_version.

For re-consent campaigns, send the re-consent email to your active subscribers (EmailConsent.active), create a new EmailConsent record rather than updating the old one, and link both records via user_id. That way you have a full audit trail showing the original consent and the updated consent.

# Re-consent flow: create a new record, don't overwrite the old one
def initiate_reconsent(user)
  return if user.email_consents.active.where(
    consent_version: ConsentText::CURRENT_VERSION
  ).exists?

  consent = EmailConsent.create!(
    email: user.email,
    user_id: user.id,
    consent_version: ConsentText::CURRENT_VERSION,
    consent_text: ConsentText::MARKETING_COPY,
    ip_address: "0.0.0.0",           # Admin-initiated, not user request
    user_agent: "re-consent-job",
    doi_token: SecureRandom.urlsafe_base64(32),
    source: "reconsent_campaign_#{Date.today}"
  )

  ReconsentMailer.request(consent).deliver_later
end

Users who don’t confirm re-consent within your window should be moved to inactive status, not deleted. Keep the old consent records. You need them to show the history of what someone agreed to and when they withdrew.

The Unsubscribe Mechanism

GDPR requires that withdrawal is as easy as giving consent. One-click unsubscribe is the standard.

Your unsubscribe endpoint takes an email address or token, calls consent.withdraw!, and shows a confirmation page. No login required. No “are you sure?” confirmation page that requires another click. That second click is friction that GDPR doesn’t allow you to add.

class UnsubscribesController < ApplicationController
  # GET /unsubscribe?token=abc123
  def show
    @consent = EmailConsent.active.find_by(doi_token: params[:token])
    # or look up by email from params[:email]
    render :not_found and return unless @consent
  end

  # POST /unsubscribe
  def create
    consent = EmailConsent.active.find_by(doi_token: params[:token])
    return render :not_found unless consent

    consent.withdraw!(reason: "user_request")
    render :confirmed
  end
end

One concern that’s worth thinking through: if your unsubscribe token is the same as your DOI token, and both are in your email footers, a scraper that harvests URLs from emails can construct valid unsubscribe URLs. Use a separate, non-guessable unsubscribe token column if this is a realistic threat for your use case.

The GDPR Audit Trail Checklist

Before you ship a signup flow, run through this:

Consent collection:

  • consent_text stores the exact wording shown to the user
  • consent_version is set and matches the current policy version
  • ip_address and user_agent recorded at collection time
  • collected_at uses a server-side timestamp, not client-provided

Email validation (before DOI send):

  • Syntax check passes
  • MX record exists and resolves
  • Disposable email addresses are rejected
  • Invalid addresses are rejected with a user-facing error

DOI flow:

  • Token is cryptographically random (not sequential, not guessable)
  • doi_confirmed_at and doi_ip_address stored on confirmation
  • Expired tokens handled gracefully with a re-send option
  • Only confirmed subscribers receive marketing mail

Withdrawal:

  • Unsubscribe works without login
  • withdrawn_at recorded immediately
  • No marketing sent after withdrawal, even if delivery is in flight
  • Old consent records preserved (don’t delete, just mark withdrawn)

Re-consent:

  • New record created rather than overwriting old consent
  • User linked via user_id for full history
  • Subscribers who don’t re-confirm are deactivated

If any box is unchecked, your audit trail has a gap. Gaps are what enforcement actions get built on.

Where the Rails Implementation Lives

For a working Rails signup flow that combines this consent model with MX-level validation before the DOI send, the Rails email validation guide covers the full model and controller setup. The pattern is the same: validate first, then write to the database, then send.

The validation step is where most developers save themselves from bounces. A fake address typed into your signup form gets caught at the MX check. It never creates a consent record. It never sends a DOI email. It never touches your sending domain’s reputation.

That sequence matters because your transactional domain is also the domain your DOI emails come from. Burning it on undeliverable mail is the same as burning it on bad marketing sends.

What an Enforcement Action Actually Looks For

Data protection authorities don’t audit randomly. They respond to complaints, investigate reported breaches, and examine companies that appear in data leaks. But when they do show up, these are the questions they ask:

Can you show us the consent record for this subscriber?

Can you show us what they were shown when they consented?

Can you show us that they took a clear affirmative action?

Can you show us that they can withdraw consent easily?

If your answers to those questions are “we’ve got a checkbox” and “it’s in our codebase somewhere,” that’s a problem. If your answers are a database query that returns a row with timestamps, IP addresses, consent text, and a confirmation event, you’re in a defensible position.

The schema above produces that second answer. The checklist gets you there.

GDPR compliance isn’t a legal feature. It’s an engineering feature that happens to have legal consequences.