emailverifier.dev
Posts

How to Prevent Free Trial Abuse Without Blocking Every Signup

| 5 min read | Usama Ejaz
Free trial abuse cover with three measured gates before a credit token.

Email verification can stop a disposable address, a dead domain, or a rejected mailbox from receiving trial credits. It cannot tell whether the same person is returning with a new Gmail address, device, payment method, or network.

A useful trial gate therefore combines independent evidence. The runnable Node.js server below uses one email-verification request, a short signup-velocity window, and an application policy that grants either no trial, a restricted trial, or the standard trial.

Define what each signal is allowed to prove

SignalUseful evidenceWhat it cannot prove
Email verificationAddress failure, disposable provider, typo, role address, or uncertainty.One person, one company, or benign intent.
Signup velocityRepeated attempts from the same observed key in a short period.That every attempt is abusive.
Device identifierRepeated use of one application-issued identifier.A durable human identity; clients can clear or spoof it.
Payment evidenceA stronger cost or continuity signal before expensive resources are granted.That a future payment will succeed or remain undisputed.

The example uses 5 restricted credits and 100 standard credits only as illustrative application settings. Replace them with the smallest amounts that let a legitimate user evaluate your product.

Build the policy engine

Create policy.mjs:

export const trialCredits = {
  restricted: 5,
  standard: 100
}

export function decideTrial({ emailReport, recentAttempts }) {
  if (emailReport.action === 'block') {
    return {
      decision: 'deny',
      credits: 0,
      reason: 'email_blocked'
    }
  }

  if (recentAttempts >= 3) {
    return {
      decision: 'review',
      credits: 0,
      reason: 'signup_velocity'
    }
  }

  if (emailReport.action === 'review') {
    return {
      decision: 'restricted',
      credits: trialCredits.restricted,
      reason: 'email_inconclusive'
    }
  }

  return {
    decision: 'standard',
    credits: trialCredits.standard,
    reason: 'checks_passed'
  }
}

A disposable signal follows the API’s block recommendation. An inconclusive mailbox response receives a small, restricted trial and can be upgraded after confirmation. Repeated attempts receive no credits until another control resolves the review.

Add the runnable signup server

Create server.mjs:

import { createServer } from 'node:http'
import { decideTrial } from './policy.mjs'

const attempts = new Map()
const windowMs = 60 * 60 * 1000
const emailPattern = /^[^\s@]+@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i

function recentAttemptCount(key, now = Date.now()) {
  const cutoff = now - windowMs
  const current = (attempts.get(key) ?? []).filter(time => time > cutoff)
  current.push(now)
  attempts.set(key, current)
  return current.length
}

async function readJson(request) {
  const chunks = []
  let bytes = 0

  for await (const chunk of request) {
    bytes += chunk.length
    if (bytes > 16_384) throw new Error('request_too_large')
    chunks.push(chunk)
  }

  return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}

async function verifyEmail(email) {
  const response = await fetch('https://emailverifier.dev/api/v1/verify', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.EMAILVERIFIER_API_KEY
    },
    body: JSON.stringify({ email }),
    signal: AbortSignal.timeout(4000)
  })

  if (!response.ok) {
    throw new Error(`verification_http_${response.status}`)
  }

  return response.json()
}

function send(response, status, body) {
  response.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8'
  })
  response.end(JSON.stringify(body))
}

createServer(async (request, response) => {
  if (request.method !== 'POST' || request.url !== '/trial-signup') {
    send(response, 404, { message: 'Not found' })
    return
  }

  let input
  try {
    input = await readJson(request)
  } catch {
    send(response, 400, { decision: 'invalid_request' })
    return
  }

  const email = typeof input.email === 'string'
    ? input.email.trim().toLowerCase()
    : ''
  const deviceId = request.headers['x-trial-device']

  if (email.length > 254 || !emailPattern.test(email) || typeof deviceId !== 'string') {
    send(response, 400, {
      decision: 'invalid_request',
      message: 'Send an email and an application-issued device identifier.'
    })
    return
  }

  try {
    const emailReport = await verifyEmail(email)
    const recentAttempts = recentAttemptCount(deviceId)
    const trial = decideTrial({ emailReport, recentAttempts })

    if (trial.decision === 'deny') {
      send(response, 422, trial)
      return
    }

    if (trial.decision === 'review') {
      send(response, 429, trial)
      return
    }

    // Atomically create the account and grant trial.credits here.
    send(response, trial.decision === 'restricted' ? 202 : 200, trial)
  } catch (error) {
    console.error(error)
    send(response, 503, {
      decision: 'retry',
      credits: 0,
      reason: 'verification_unavailable'
    })
  }
}).listen(3000, () => {
  console.log('Trial signup listening on http://localhost:3000')
})

verifyEmail() contains one external request and no automatic retry. The in-memory velocity map is suitable for running the example, not for multiple production instances. Replace it with an atomic, expiring counter in the datastore already used by your application.

The X-Trial-Device header is also not proof of a physical device. Issue and sign this identifier in your own application, then combine it with account, network, payment, and product-usage evidence according to your privacy obligations.

Test the decisions without calling the API

Create policy.test.mjs:

import assert from 'node:assert/strict'
import test from 'node:test'

import { decideTrial } from './policy.mjs'

test('disposable result gets no trial credits', () => {
  const result = decideTrial({
    emailReport: { action: 'block' },
    recentAttempts: 1
  })

  assert.deepEqual(result, {
    decision: 'deny',
    credits: 0,
    reason: 'email_blocked'
  })
})

test('unknown result receives restricted access', () => {
  const result = decideTrial({
    emailReport: { action: 'review' },
    recentAttempts: 1
  })

  assert.equal(result.decision, 'restricted')
  assert.equal(result.credits, 5)
})

test('velocity overrides an allowed email', () => {
  const result = decideTrial({
    emailReport: { action: 'allow' },
    recentAttempts: 3
  })

  assert.equal(result.decision, 'review')
  assert.equal(result.credits, 0)
})

Run node --test.

Make one live trial request

Create a project and copy its API key, then start the server:

EMAILVERIFIER_API_KEY="your-project-key" node server.mjs
curl http://localhost:3000/trial-signup \
  -H "Content-Type: application/json" \
  -H "X-Trial-Device: signed-device-id" \
  -d '{"email":"person@example.com"}'

The route returns 422 deny, 429 review, 202 restricted, 200 standard, or 503 retry. Trial-credit creation belongs in the marked atomic boundary so two concurrent requests cannot receive the same grant twice.

Email verification removes one cheap path to repeated trials. It does not replace rate limits, confirmation, idempotency, payment controls, or monitoring. The validation and confirmation guide explains where inbox ownership fits beside the pre-signup check.

Continue reading