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-startedconst 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 handlerconst decision = await aj.protect(request, { email });
// Evaluate the various Arcjet checksif (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 }}
function isSpoofed(result) { return ( // You probably don't want DRY_RUN rules resulting in a denial // since they are generally used for evaluation purposes but you // could log here. result.state !== "DRY_RUN" && result.reason.isBot() && result.reason.isSpoofed() );}
// Arcjet Pro plan verifies the authenticity of common bots using IP data.// Verification isn't always possible, so we recommend checking the decision// separately.// https://docs.arcjet.com/bot-protection/reference#bot-verificationif (decision.results.some(isSpoofed)) { console.error("Spoofed bot detected", 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 providerif (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 UKif (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.
# Install shadcn components from https://ui.shadcn.com/docs/installation/nextnpx shadcn@latest add button card input label toast use-toast
This will install the following components:
- 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.
"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:
import arcjet, { type ArcjetRuleResult, 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 }), ],});
function isSpoofed(result: ArcjetRuleResult) { return ( // You probably don't want DRY_RUN rules resulting in a denial // since they are generally used for evaluation purposes but you // could log here. result.state !== "DRY_RUN" && result.reason.isBot() && result.reason.isSpoofed() );}
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 }, ); } }
// Arcjet Pro plan verifies the authenticity of common bots using IP data. // Verification isn't always possible, so we recommend checking the decision // separately. // https://docs.arcjet.com/bot-protection/reference#bot-verification if (decision.results.some(isSpoofed)) { console.error("Spoofed bot detected", 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 }, ); }}