Skip to content

Payment form protection

Arcjet can help you protect your payment form from fraudulent credit card transactions. The goal is to block high risk transactions before they are submitted to your credit card processor to save you money on chargeback fees.

Credit card processors are able to use other signals to detect fraud once they have the full transaction details, so we recommend using this as a first defense to block the most obvious fraud attempts.

Pre-checkout protection

A common approach for handling payments is to generate a unique payment or checkout link e.g. Stripe Checkout. This avoids having to collect credit card details on your own servers.

In this example, Arcjet will analyze the user request before checkout. If the request is high risk, the user will be blocked. If the request is considered safe, you can then proceed to generate the payment link and redirect the user to the payment processor.

Rules

These are the Arcjet rules we recommend using to protect the checkout link. It is configured to block all bots and automated clients, to validate the email address (including blocking free and disposable emails), and to limit the number of requests from the same IP address.

If you expect customers to use emails from free providers like Gmail, Hotmail, etc then you may wish to remove FREE from the block list.

// ... imports, etc
// See https://docs.arcjet.com/get-started
const aj = arcjet({
key: process.env.ARCJET_KEY!, // Get your site key from https://app.arcjet.com
characteristics: ["ip.src"], // Track requests by IP
rules: [
// Shield protects your app from common attacks e.g. SQL injection
shield({ mode: "LIVE" }),
// Create a bot detection rule
detectBot({
mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only
// Block all bots. See https://arcjet.com/bot-list
allow: [],
}),
validateEmail({
mode: "LIVE",
// Strict email validation to block disposable, invalid, free, and domains
// with no valid MX records. Free emails include Gmail, Hotmail, Yahoo,
// etc, so you may wish to remove this rule
block: ["DISPOSABLE", "INVALID", "NO_MX_RECORDS", "FREE"],
}),
// It would be unusual for a form to be submitted more than 5 times in 10
// minutes from the same IP address
slidingWindow({
mode: "LIVE",
interval: "10m", // counts requests over a 10 minute sliding window
max: 5, // allows 5 submissions within the window
}),
],
});

Additional checks

If the base rules pass then you can use IP geolocation and VPN & proxy detection checks to further validate the transaction.

This example only allows users from the US and UK, and blocks users from hosting providers, VPNs, and proxies.

// Follows on from the rules set above.
// This would go inside your request handler
const decision = await aj.protect(request, { email });
// Evaluate the various Arcjet checks
if (decision.isDenied()) {
if (decision.reason.isBot()) {
console.error("Bot detected", decision);
// Return 403
} else if (decision.reason.isRateLimit()) {
console.error("Rate limit exceeded", decision);
// Return 429 or 403
} else if (decision.reason.isEmail()) {
console.error("Invalid email", decision);
// Return 400
} else {
console.error("Request denied", decision);
// Return 403
}
}
// Base Arcjet rules all passed, but we can do further inspection based on
// our knowledge of our customers
// Check if the IP address is from a hosting provider
if (decision.ip.hasASN() && decision.ip.asnType === "hosting") {
// The network this IP belongs to is a hosting provider, which makes it
// more likely to be a VPN, proxy, or other suspicious network.
console.error("Hosting provider detected", decision);
// Return 403
}
if (
decision.ip.isHosting() ||
decision.ip.isVpn() ||
decision.ip.isProxy() ||
decision.ip.isRelay()
) {
// The IP is from a hosting provider, VPN, or proxy. We can check the name
// of the service and customize the response
if (decision.ip.hasService()) {
if (decision.ip.service !== "Apple Private Relay") {
// We trust Apple Private Relay because it requires an active iCloud
// subscription, so deny all other VPNs
console.error("VPN detected", decision);
// Return 403
} else {
// Apple Private Relay is allowed
console.info("Apple Private Relay detected", decision);
}
} else {
// The service name is not available, but we still think it's a VPN
console.error("VPN detected", decision);
// Return 403
}
}
// Only allow users from the US and UK
if (decision.ip.hasCountry() && !["US", "UK"].includes(decision.ip.country)) {
console.error("Country not allowed", decision);
// Return 403
}
console.info("Arcjet checks passed", decision.id);

Next.js example

This example shows a form component which implements the above rules. It uses the Next.js app router and shadcn/ui.

Terminal window
# Install shadcn components from https://ui.shadcn.com/docs/installation/next
npx shadcn@latest add button card input label toast use-toast

This will install the following components:

Terminal window
- components/ui/button.tsx
- components/ui/card.tsx
- components/ui/input.tsx
- components/ui/label.tsx
- components/ui/toast.tsx
- components/ui/toaster.tsx
- hooks/use-toast.ts

The payment form component is shown below. If the form passes the Arcjet checks, it will redirect the user to the link for the payment processor.

/app/components/Form.tsx
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Toaster } from "@/components/ui/toaster";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function PaymentForm() {
const [amount, setAmount] = useState("10.00");
const [email, setEmail] = useState("user@example.com");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
setIsLoading(true);
const response = await fetch("/api/process-payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, amount }),
});
setIsLoading(false);
const responseData = await response.json();
if (response.ok) {
// Redirect to an external URL
window.location.href = responseData.paymentLink;
// Or if you are self-hosting the payment form, redirect to path
//router.push(responseData.paymentLink);
} else {
toast({
title: "Payment Failed",
description:
"There was an error processing your payment: " + responseData.message,
variant: "destructive",
});
}
}
return (
<>
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Purchase Form</CardTitle>
<CardDescription>Enter your details below</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Processing..." : "Pay Now"}
</Button>
</form>
</CardContent>
</Card>
<Toaster />
</>
);
}

The form submission is processed by the following API route:

/app/api/process-payment/route.ts
import arcjet, {
detectBot,
shield,
slidingWindow,
validateEmail,
} from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!, // Get your site key from https://app.arcjet.com
characteristics: ["ip.src"], // Track requests by IP
rules: [
// Shield protects your app from common attacks e.g. SQL injection
shield({ mode: "LIVE" }),
// Create a bot detection rule
detectBot({
mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only
// Block all bots. See https://arcjet.com/bot-list
allow: [],
}),
validateEmail({
mode: "LIVE",
// Strict email validation to block disposable, invalid, free, and domains
// with no valid MX records. Free emails include GMail, Hotmail, Yahoo,
// etc, so you may wish to remove this rule
block: ["DISPOSABLE", "INVALID", "NO_MX_RECORDS", "FREE"],
}),
// It would be unusual for a form to be submitted more than 5 times in 10
// minutes from the same IP address
slidingWindow({
mode: "LIVE",
interval: "10m", // counts requests over a 10 minute sliding window
max: 5, // allows 5 submissions within the window
}),
],
});
export async function POST(request: Request) {
try {
const body = await request.json();
const { email, amount } = body;
const decision = await aj.protect(request, { email });
// Evaluate the various Arcjet checks
if (decision.isDenied()) {
if (decision.reason.isBot()) {
console.error("Bot detected", decision);
return NextResponse.json(
{ success: false, message: "Forbidden" },
{ status: 403 },
);
} else if (decision.reason.isRateLimit()) {
console.error("Rate limit exceeded", decision);
return NextResponse.json(
{ success: false, message: "Please try again in a few minutes" },
{ status: 429 },
);
} else if (decision.reason.isEmail()) {
console.error("Invalid email", decision);
return NextResponse.json(
{ success: false, message: "Invalid email address" },
{ status: 400 },
);
} else {
console.error("Request denied", decision);
return NextResponse.json(
{ success: false, message: "Forbidden" },
{ status: 403 },
);
}
}
// Base Arcjet rules all passed, but we can do further inspection based on
// our knowledge of our customers
// Check if the IP address is from a hosting provider
if (decision.ip.hasASN() && decision.ip.asnType === "hosting") {
// The network this IP belongs to is a hosting provider, which makes it
// more likely to be a VPN, proxy, or other suspicious network.
console.error("Hosting provider detected", decision);
return NextResponse.json(
{ success: false, message: "Forbidden" },
{ status: 403 },
);
}
if (
decision.ip.isHosting() ||
decision.ip.isVpn() ||
decision.ip.isProxy() ||
decision.ip.isRelay()
) {
// The IP is from a hosting provider, VPN, or proxy. We can check the name
// of the service and customize the response
if (decision.ip.hasService()) {
if (decision.ip.service !== "Apple Private Relay") {
// We trust Apple Private Relay because it requires an active iCloud
// subscription, so deny all other VPNs
console.error("VPN detected", decision);
return NextResponse.json(
{ success: false, message: "Forbidden" },
{ status: 403 },
);
} else {
// Apple Private Relay is allowed
console.info("Apple Private Relay detected", decision);
}
} else {
// The service name is not available, but we still think it's a VPN
console.error("VPN detected", decision);
return NextResponse.json(
{ success: false, message: "Forbidden" },
{ status: 403 },
);
}
}
// Only allow users from the US and UK
if (
decision.ip.hasCountry() &&
!["US", "UK"].includes(decision.ip.country)
) {
console.error("Country not allowed", decision);
return NextResponse.json(
{ success: false, message: "Forbidden" },
{ status: 403 },
);
}
console.info("Arcjet checks passed", decision.id);
// This is where you would generate the checkout link. Return it to the form
// component to redirect the user to the payment page. See
// https://docs.stripe.com/checkout/quickstart?lang=node&client=next for an
// example with Stripe Checkout
return NextResponse.json({
success: true,
paymentLink: "https://www.example.com",
});
} catch (error) {
console.error("Payment processing error:", error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 },
);
}
}