Zuplo logo
Back to all articles

Block Spam Signups with Zuplo and Your Identity Providers

June 5, 2025
32 min read
Nate Totten
Nate TottenCo-founder & CTO

Email-based spam and fake accounts are a persistent challenge for any online service. At Zuplo, we've built a robust system that validates user emails during the authentication flow, blocking disposable email addresses, known spam domains, and suspicious patterns. In this tutorial, we'll show you how to implement a similar system using Zuplo and your identity provider's extensibility features.

The Problem#

When building a SaaS product, you'll inevitably encounter users who sign up with:

  • Disposable email addresses (like 10minutemail.com)
  • Known spam domains
  • Suspicious email patterns often used by bad actors
  • Free email providers (which you may want to restrict for B2B products)

These accounts can skew your metrics, abuse free trials, or attempt to exploit your service. By implementing email validation at the authentication layer, you can stop these users before they ever access your application.

The Solution: Identity Provider Extensibility + Zuplo API#

Most modern identity providers offer extensibility features that allow you to run custom code during authentication flows. Whether you're using Auth0 Actions, Okta Hooks, AWS Cognito Lambda Triggers, or similar features, you can integrate a Zuplo-powered email validation API.

Our solution combines:

  1. Identity Provider Hooks - Custom code that runs during login flows
  2. Zuplo API - A dedicated email validation service
  3. SendGrid Email Validation - Third-party email verification
  4. Curated Block Lists - Continuously updated lists of disposable and spam domains
  5. GitHub Actions - Automated updates to keep block lists current

For this tutorial, we'll use Auth0 Actions as our example, but the pattern works with any identity provider that supports custom authentication logic.

Step 1: Create the Zuplo Email Validation API#

First, let's build the API that will handle email validation. If you're new to Zuplo, check out the Getting Started guide and Custom Request Handlers documentation.

Create a new Zuplo project and add the following modules:

Core API Module (modules/api.ts)#

This module contains the main validation logic. If you're not familiar with Zuplo's module system, check out the Reusing Code documentation.

import { environment, Logger } from "@zuplo/runtime";
import custom from "./custom";
import disposable from "./disposable";
import free from "./free";

export interface SpamCheckData {
  email: string;
  ipAddress?: string;
  userAgent?: string;
  countryCode?: string;
}

export interface CheckResult {
  isBlocked: boolean;
  code: string;
  reason: string;
}

export async function check(
  data: SpamCheckData,
  logger: Logger,
): Promise<CheckResult> {
  // Validate email with SendGrid
  const emailResult = await validateEmail(data.email, logger);

  // Check allow list first
  if (
    custom.allowed.domains.includes(emailResult.host) ||
    custom.allowed.emails.includes(emailResult.email)
  ) {
    return {
      isBlocked: false,
      code: "allowed",
      reason: "All checks passed.",
    };
  }

  // Check if domain is on block list
  if (custom.blocked.domains.includes(emailResult.host)) {
    return {
      isBlocked: true,
      code: "blocked-domain",
      reason: "Domain is on the block list.",
    };
  }

  // Check if email is on block list
  if (custom.blocked.emails.includes(emailResult.email)) {
    return {
      isBlocked: true,
      code: "blocked-email",
      reason: "Email is on the block list.",
    };
  }

  // Check if domain is disposable
  if (disposable.includes(emailResult.host)) {
    return {
      isBlocked: true,
      code: "disposable-domain",
      reason: "Domain is suspected of being disposable.",
    };
  }

  // Check if domain is a free email provider
  if (free.includes(emailResult.host)) {
    return {
      isBlocked: true,
      code: "free-domain",
      reason: "Domain is a free email provider.",
    };
  }

  return {
    isBlocked: false,
    code: "allowed",
    reason: "All checks passed.",
  };
}

Request Handler (modules/handlers.ts)#

Create the HTTP endpoint that Auth0 will call. Zuplo uses the standard Web API Request/Response pattern:

import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
import { SpamCheckData, check } from "./api";

export async function checkHandler(
  request: ZuploRequest,
  context: ZuploContext,
) {
  const data = (await request.json()) as SpamCheckData;

  context.log.info(`Performing spam check on ${data.email}`);

  try {
    const result = await check(data, context.log);

    return new Response(JSON.stringify(result, null, 2), {
      status: 200,
      headers: {
        "content-type": "application/json",
      },
    });
  } catch (err) {
    context.log.error("Error during spam check", err);
    throw err;
  }
}

Block Lists (modules/custom.ts, modules/disposable.ts, modules/free.ts)#

Create modules for your block lists:

// modules/custom.ts
const custom = {
  allowed: {
    domains: ["yourcompany.com", "partner.com"],
    emails: ["vip@example.com"],
  },
  blocked: {
    domains: ["spammer.com", "badactor.net"],
    emails: ["known-spammer@example.com"],
  },
};
export default custom;

// modules/disposable.ts
// This list is auto-updated by GitHub Actions
const list = ["10minutemail.com", "guerrillamail.com", "mailinator.com"];
export default list;

// modules/free.ts
const list = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com"];
export default list;

Configure the Route#

In your Zuplo routes.oas.json file, add the route configuration. Zuplo uses OpenAPI 3.1 for route definitions:

{
  "paths": {
    "/check": {
      "post": {
        "summary": "Check email for spam",
        "x-zuplo-route": {
          "handler": {
            "export": "checkHandler",
            "module": "$import(./modules/handlers)"
          },
          "policies": {
            "inbound": ["api-key-auth"]
          }
        }
      }
    }
  }
}

Step 2: Set Up SendGrid Email Validation#

  1. Get a SendGrid API key with email validation permissions
  2. Add it to your Zuplo environment variables as SENDGRID_TOKEN
  3. Mark it as "Secret" for security
  4. The API will use SendGrid to check for:
    • Valid email syntax
    • MX records
    • Known bounces
    • Suspected role addresses

Step 3: Create the Identity Provider Integration#

Most identity providers offer extensibility points during authentication. Here's how to integrate your Zuplo API using Auth0 Actions as an example:

// Example: Auth0 Action
exports.onExecutePostLogin = async (event, api) => {
  // Skip for SSO connections
  const isRegularConnection =
    event.connection.strategy === "auth0" ||
    event.connection.strategy === "google-oauth2";

  if (!isRegularConnection) {
    return;
  }

  // Call your Zuplo API
  const response = await fetch("https://your-api.zuplo.app/check", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${event.secrets.ZUPLO_API_KEY}`,
    },
    body: JSON.stringify({
      email: event.user.email,
      ipAddress: event.request.ip,
      userAgent: event.request.user_agent,
      countryCode: event.request.geoip?.countryCode,
    }),
  });

  if (response.status !== 200) {
    console.error("Error calling spam check API");
    return;
  }

  const result = await response.json();

  if (result.isBlocked) {
    // Log the blocked attempt
    console.warn(`Blocked login attempt: ${result.reason}`);

    // Deny access and redirect to blocked page
    api.access.deny("https://yourapp.com/blocked");
    return;
  }

  // Continue with login
};

Integration with Other Identity Providers#

The same pattern works with other providers:

  • Okta: Use Event Hooks or Inline Hooks
  • AWS Cognito: Use Lambda Triggers (Pre-authentication)
  • Firebase Auth: Use Blocking Functions
  • Supabase: Use Database Functions and Triggers
  • Clerk: Use Webhooks and Backend API

Each provider has its own syntax, but the core pattern remains the same: intercept the login flow, call your Zuplo API, and block or allow based on the response.

Step 4: Automate List Updates with GitHub Actions#

Keep your disposable email list current with this GitHub Action. This action fetches the open source disposable email domains list and updates your Zuplo module automatically.

name: Update Email Lists
on:
  workflow_dispatch:
  schedule:
    - cron: "0 1 * * *" # Daily at 1 AM

jobs:
  update-lists:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Update Disposable Email List
        run: |
          # Fetch latest disposable domains from a public source
          curl -s https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.json | \
          jq -r '.[]' | \
          node -e "
            const fs = require('fs');
            let data = '';
            process.stdin.on('data', chunk => data += chunk);
            process.stdin.on('end', () => {
              const domains = data.trim().split('\n');
              const content = 'const list = ' + JSON.stringify(domains, null, 2) + ';\nexport default list;';
              fs.writeFileSync('./modules/disposable.ts', content);
            });
          "

      - name: Commit and Push
        run: |
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add ./modules/disposable.ts
          git commit -m "Update disposable email list" || exit 0
          git push

Step 5: Advanced Features#

Slack Notifications#

Get notified when blocking users. You can use Zuplo's logging plugins or implement custom notifications:

if (result.isBlocked) {
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      text: `🚫 Blocked signup attempt`,
      blocks: [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `*Email:* ${email}\n*Reason:* ${result.reason}`,
          },
        },
      ],
    }),
  });
}

Performance Optimization#

Depending on your business requirements, you have several options to optimize the API performance:

  • Database Caching - Store validation results in a database like Supabase or cache like Upstash to avoid repeated SendGrid calls for the same email addresses
  • Identity Provider Metadata - Use your provider's user metadata features (like Auth0's app_metadata) to mark users as allowed/validated to skip checks on subsequent logins
  • Hybrid Approach - Combine both strategies based on your security needs

Note that your caching strategy depends on your business rules. If you want to catch users who were initially allowed but later ended up on a block list, you'll need to run checks on every login. If you're comfortable with a "validate once" approach, caching can significantly reduce API calls and improve login performance.

Benefits of This Approach#

  1. Centralized Validation - All email checks happen in one place
  2. Easy Updates - Block lists update automatically without touching Auth0
  3. Flexible Rules - Easy to add new validation logic
  4. Performance - Database caching reduces API calls
  5. Monitoring - Get notified about blocked attempts
  6. Scalable - Zuplo handles the API scaling automatically across 300+ edge locations

Best Practices#

  1. Allow Lists - Always maintain an allow list for legitimate domains you trust
  2. Gradual Rollout - Start by logging suspicious emails before blocking
  3. User Communication - Provide clear messaging when blocking users
  4. Regular Reviews - Periodically review blocked emails for false positives
  5. API Security - Always use API keys to secure your validation endpoint
  6. Request Validation - Use Zuplo's request validation policies to ensure proper request format

Conclusion#

By combining your identity provider's extensibility features with a Zuplo API, you can create a powerful email validation system that protects your application from spam and abuse. The modular design makes it easy to customize rules for your specific needs, while automation keeps your block lists current without manual intervention.

This approach has helped us at Zuplo maintain high-quality user signups while preventing abuse. Whether you're using Auth0, Okta, Cognito, or any other modern identity provider, you can implement similar protection for your applications.

Resources#

Identity Provider Documentation#

Zuplo Documentation#

Additional Resources#