How to ratelimit public pages

Learn how to use Unkey to ratelimit public pages, like newsletter sign up forms or login attempts.

Written by

James Perkins

Published on

Consider a typical authentication form. Without protection, someone could:

  • Submit thousands of fake email addresses, polluting your database
  • Attempt to guess valid email addresses through enumeration
  • Overload your server with excessive requests
  • Abuse authentication endpoints by trying random email/password combinations

Traditional rate limiting based on IP addresses has limitations. Users can bypass IP-based restrictions using VPNs, proxy servers, or botnets. This is where device fingerprinting shines.

Device Fingerprinting: A Better Approach

Device fingerprinting creates a unique identifier for each device based on browser characteristics, hardware information, and other attributes. Unlike IP addresses, fingerprints are much harder to spoof and provide a more reliable way to identify individual devices.

The thumbmark.js library generates comprehensive fingerprints using:

  • Browser characteristics (user agent, platform, language)
  • Screen resolution and color depth
  • Hardware information (CPU cores, memory)
  • Canvas and WebGL rendering signatures
  • Audio context fingerprinting
  • Installed fonts and plugins

This creates a robust identifier that persists across sessions and IP changes.

Implementing Rate Limiting with Unkey

Let's walk through a complete implementation using Next.js, Unkey for rate limiting, and thumbmark.js for device fingerprinting. You can see the full example in the unkey-fingerprint repository.

Step 1: Setting Up Unkey

First, create an Unkey account and generate a root key with rate limiting permissions:

1# Add to your .env.local
2UNKEY_ROOT_KEY=your_unkey_root_key_here

Step 2: Client-Side Fingerprinting

Install the required dependencies:

1npm install @thumbmarkjs/thumbmarkjs @unkey/ratelimit

Create a React component that generates the device fingerprint:

1"use client";
2
3import { useState, useEffect } from "react";
4import { getThumbmark } from "@thumbmarkjs/thumbmarkjs";
5import type { thumbmarkResponse } from "@/lib/fingerprint-validation";
6
7export default function WaitlistPage() {
8  const [fingerprintData, setFingerprintData] =
9    useState<thumbmarkResponse | null>(null);
10
11  useEffect(() => {
12    // Generate device fingerprint when component mounts
13    const generateFingerprint = async () => {
14      try {
15        const result = await getThumbmark();
16        setFingerprintData(result);
17      } catch (error) {
18        console.error("Error generating fingerprint:", error);
19      }
20    };
21
22    generateFingerprint();
23  }, []);
24
25  const handleSubmit = async (e: React.FormEvent) => {
26    e.preventDefault();
27
28    if (!fingerprintData) {
29      // Handle case where fingerprint isn't ready
30      return;
31    }
32
33    const response = await fetch("/api/waitlist", {
34      method: "POST",
35      headers: { "Content-Type": "application/json" },
36      body: JSON.stringify({
37        email,
38        fingerprintData,
39      }),
40    });
41
42    // Handle response...
43  };
44
45  // Rest of your component...
46}

Step 3: Server-Side Validation

Create a robust validation system for fingerprint data:

1// lib/fingerprint-validation.ts
2export interface ValidationResult {
3  isValid: boolean;
4  errors: string[];
5}
6
7export function validateFingerprint(
8  fingerprintData: unknown,
9): ValidationResult {
10  const errors: string[] = [];
11
12  if (!fingerprintData || typeof fingerprintData !== "object") {
13    errors.push("Invalid fingerprint data format");
14    return { isValid: false, errors };
15  }
16
17  const data = fingerprintData as Record<string, unknown>;
18
19  // Check required fields
20  if (!data.components || !data.thumbmark) {
21    errors.push("Missing required fingerprint components");
22    return { isValid: false, errors };
23  }
24
25  // Validate structure and realism
26  const components = data.components as Record<string, any>;
27
28  if (!validateFingerprintStructure(components)) {
29    errors.push("Invalid fingerprint structure");
30  }
31
32  if (!validateFingerprintRealism(components)) {
33    errors.push("Fingerprint appears to be fake or unrealistic");
34  }
35
36  return {
37    isValid: errors.length === 0,
38    errors,
39  };
40}

Step 4: API Endpoint with Rate Limiting

Create the API endpoint that combines fingerprint validation with Unkey rate limiting:

1// app/api/waitlist/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { validateFingerprint } from "@/lib/fingerprint-validation";
4import { Ratelimit } from "@unkey/ratelimit";
5
6const limiter = new Ratelimit({
7  rootKey: process.env.UNKEY_ROOT_KEY!,
8  duration: 3600000, // 1 hour
9  limit: 3, // 3 requests per hour
10  async: false,
11  namespace: "waitlist",
12});
13
14export async function POST(request: NextRequest) {
15  try {
16    const body = await request.json();
17    const { email, fingerprintData } = body;
18
19    // Validate required fields
20    if (!email || !fingerprintData) {
21      return NextResponse.json(
22        {
23          error: "Email and device fingerprint are required",
24          success: false,
25        },
26        { status: 400 },
27      );
28    }
29
30    // Validate email format
31    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
32    if (!emailRegex.test(email)) {
33      return NextResponse.json(
34        {
35          error: "Invalid email format",
36          success: false,
37        },
38        { status: 400 },
39      );
40    }
41
42    // Validate fingerprint data
43    const validation = validateFingerprint(fingerprintData);
44    if (!validation.isValid) {
45      return NextResponse.json(
46        {
47          error: "Invalid or suspicious device fingerprint",
48          success: false,
49        },
50        { status: 400 },
51      );
52    }
53
54    // Check rate limit using Unkey with the validated fingerprint
55    const { success, limit, remaining, reset } = await limiter.limit(
56      fingerprintData.thumbmark,
57    );
58
59    if (!success) {
60      const resetTime = new Date(Date.now() + reset);
61      return NextResponse.json(
62        {
63          error: "Rate limit exceeded. Please try again later.",
64          success: false,
65          rateLimited: true,
66          resetTime: resetTime.toISOString(),
67          remaining,
68          limit,
69        },
70        { status: 429 },
71      );
72    }
73
74    // Process the request (e.g., save to database)
75    console.log("New signup:", {
76      email,
77      fingerprint: fingerprintData.thumbmark,
78    });
79
80    return NextResponse.json({
81      success: true,
82      message: "Successfully added to waitlist!",
83      remaining,
84      limit,
85    });
86  } catch (error) {
87    return NextResponse.json(
88      {
89        error: "Internal server error",
90        success: false,
91      },
92      { status: 500 },
93    );
94  }
95}

Security Best Practices

When implementing fingerprint-based rate limiting, follow these security practices:

1. Comprehensive Fingerprint Validation

Never trust client-provided fingerprint data. Validate:

  • Structure: Ensure all required components are present
  • Data Types: Verify each component has the correct type
  • Realism: Check for obviously fake values (unreasonable screen resolutions, invalid user agents)
  • Hash Integrity: Validate that the fingerprint hash matches the components

2. Fallback Mechanisms

Implement proper fallback handling for when Unkey is unavailable:

1const fallback = (identifier: string) => ({
2  success: false,
3  limit: 0,
4  reset: 0,
5  remaining: 0,
6});
7
8const limiter = new Ratelimit({
9  // ... other config
10  timeout: {
11    ms: 3000,
12    fallback,
13  },
14  onError: (err, identifier) => {
15    console.error(`${identifier} - ${err.message}`);
16    return fallback(identifier);
17  },
18});

3. User Experience

Provide clear feedback about rate limits when a user exceeds their limit, in this repo example we just show a simple message:

1{
2  rateLimitInfo && (
3    <div className="mt-6 p-4 bg-blue-50 rounded-lg">
4      <div className="flex items-center space-x-2 text-sm text-blue-600">
5        <Clock className="h-4 w-4" />
6        <span>
7          {rateLimitInfo.remaining} of {rateLimitInfo.limit} requests remaining
8          this hour
9        </span>
10      </div>
11    </div>
12  );
13}

Advanced Considerations

Handling Legitimate Users

Sometimes legitimate users might trigger rate limits (family sharing devices, public computers). Consider:

  • Providing a way for users to request limit increases
  • Implementing progressive rate limiting (stricter limits for new users)
  • Adding CAPTCHA challenges for suspicious activity

Privacy Implications

Device fingerprinting raises privacy concerns. Be transparent about:

  • What data you're collecting
  • How you're using it
  • How users can opt out if needed

Monitoring and Analytics

Track rate limiting effectiveness:

1// Log rate limit violations for monitoring
2if (!success) {
3  console.warn(`Rate limit exceeded: ${fingerprintData.thumbmark}`);
4  // Send to monitoring service
5}

Conclusion

Device fingerprinting combined with Unkey's rate limiting provides a robust solution for protecting public endpoints. This approach is more reliable than IP-based restrictions and offers better protection against automated abuse.

The complete example demonstrates how to implement this pattern securely, with proper validation, error handling, and user experience considerations. By following these practices, you can protect your public endpoints while maintaining a smooth experience for legitimate users.

For the full implementation, check out the unkey-fingerprint repository on GitHub.

Protect your API.
Start today.

150,000 requests per month. No CC required.