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:
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 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:
This creates a robust identifier that persists across sessions and IP changes.
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.
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_hereInstall the required dependencies:
1npm install @thumbmarkjs/thumbmarkjs @unkey/ratelimitCreate 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}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}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}When implementing fingerprint-based rate limiting, follow these security practices:
Never trust client-provided fingerprint data. Validate:
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});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}Sometimes legitimate users might trigger rate limits (family sharing devices, public computers). Consider:
Device fingerprinting raises privacy concerns. Be transparent about:
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}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.
150,000 requests per month. No CC required.