DEV Community

Cover image for Block Spam Signups with Zuplo and Your Identity Providers
Adrian Machado for Zuplo

Posted on • Originally published at zuplo.com

Block Spam Signups with Zuplo and Your Identity Providers

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.", }; } 
Enter fullscreen mode Exit fullscreen mode

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; } } 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

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"] } } } } } } 
Enter fullscreen mode Exit fullscreen mode

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:
  5. Valid email syntax
  6. MX records
  7. Known bounces
  8. 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 }; 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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}`, }, }, ], }), }); } 
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)