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:
- Identity Provider Hooks - Custom code that runs during login flows
- Zuplo API - A dedicated email validation service
- SendGrid Email Validation - Third-party email verification
- Curated Block Lists - Continuously updated lists of disposable and spam domains
- 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
- Get a SendGrid API key with email validation permissions
- Add it to your Zuplo environment variables as
SENDGRID_TOKEN
- Mark it as "Secret" for security
- 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
- Centralized Validation - All email checks happen in one place
- Easy Updates - Block lists update automatically without touching Auth0
- Flexible Rules - Easy to add new validation logic
- Performance - Database caching reduces API calls
- Monitoring - Get notified about blocked attempts
- Scalable - Zuplo handles the API scaling automatically across 300+ edge locations
Best Practices
- Allow Lists - Always maintain an allow list for legitimate domains you trust
- Gradual Rollout - Start by logging suspicious emails before blocking
- User Communication - Provide clear messaging when blocking users
- Regular Reviews - Periodically review blocked emails for false positives
- API Security - Always use API keys to secure your validation endpoint
- 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
- Zuplo Documentation
- Getting Started Guide
- Custom Request Handlers
- API Key Authentication
- Environment Variables
Top comments (0)