Skip to content

arcjet/arcjet-go

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Arcjet Logo

Arcjet - Go SDK

Important

The Go SDK is pre-release and unstable.

Arcjet is the runtime security platform that ships with your AI code. Stop bots and automated attacks from burning your AI budget, leaking data, or misusing tools with Arcjet's AI security building blocks.

This is the Go SDK for Arcjet — use arcjet.NewClient for request protection in net/http handlers (and any router that exposes *http.Request) and arcjet.NewGuardClient for guard protection (AI agent tool calls, MCP servers, background jobs, queue workers).

Getting started

Install the Arcjet CLI

The CLI is used to log in, manage site keys, and install protection skills.

Homebrew (macOS and Linux):

brew install arcjet/tap/arcjet

npx (Node.js) — run any command without installing:

npx @arcjet/cli <command>

Or download a binary for macOS (Apple Silicon, Intel), Linux (x86_64, arm64), and Windows (x86_64, arm64).

Examples below use the arcjet binary. If you installed via npx, replace arcjet with npx @arcjet/cli.

Quick setup with an AI agent

  1. Log in with the CLI:
    arcjet auth login
  2. Install the protection skill:
    npx skills add arcjet/skills
  3. Tell your agent what to protect — it handles the rest.

Manual setup

  1. Log in with the CLI (or at app.arcjet.com):
    arcjet auth login
  2. go get github.com/arcjet/arcjet-go
  3. Get your site key:
    arcjet sites get-key
    Or copy it from the Arcjet dashboard.
  4. Set ARCJET_KEY=ajkey_yourkey in your environment.
  5. Protect a handler — see the AI protection example or individual feature examples below.

Get help

Join our Discord server or reach out for support.

Quick start

Note: Create the client once at package scope and reuse it across handlers. For larger projects, move it into its own package (e.g. internal/security/arcjet.go) so handlers can import a single shared instance.

Protect an AI chat endpoint with prompt injection detection, token budget rate limiting, and bot protection:

// main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/arcjet/arcjet-go"
)

var arcjetKey = func() string {
	key := os.Getenv("ARCJET_KEY")
	if key == "" {
		log.Fatal("ARCJET_KEY is required. Get one with: arcjet sites get-key" +
			" or from https://app.arcjet.com")
	}
	return key
}()

// Create a single Arcjet client and reuse it across requests.
var aj = must(arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: []arcjet.Rule{
		// Detect and block prompt injection attacks in user messages.
		arcjet.DetectPromptInjection(arcjet.PromptInjectionOptions{
			Mode: arcjet.ModeLive,
		}),
		// Rate limit by token budget — refill 100 tokens every 60 seconds.
		arcjet.TokenBucket(arcjet.TokenBucketOptions{
			Mode:            arcjet.ModeLive,
			Characteristics: []string{"userId"},
			RefillRate:      100,
			Interval:        time.Minute,
			Capacity:        1000,
		}),
		// Block automated clients and scrapers from your AI endpoints.
		arcjet.DetectBot(arcjet.BotOptions{
			Mode:  arcjet.ModeLive,
			Allow: []string{}, // empty = block all bots
		}),
		// Protect against common web attacks (SQLi, XSS, etc.).
		arcjet.Shield(arcjet.ShieldOptions{Mode: arcjet.ModeLive}),
	},
}))

type chatRequest struct {
	Message string `json:"message"`
}

func chat(w http.ResponseWriter, r *http.Request) {
	var body chatRequest
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	userID := "user_123" // replace with real user ID from session

	decision, err := aj.Protect(
		r.Context(),
		r,
		arcjet.WithRequested(5), // tokens consumed per request
		arcjet.WithCharacteristics(map[string]string{"userId": userID}),
		arcjet.WithDetectPromptInjectionMessage(body.Message), // scan for prompt injection
	)
	if err != nil {
		// Arcjet fails open — log and continue serving.
		log.Printf("arcjet: %v", err)
	} else if decision.IsDenied() {
		status := http.StatusForbidden
		if decision.Reason.IsRateLimit() {
			status = http.StatusTooManyRequests
		}
		http.Error(w, "denied", status)
		return
	}

	// Safe to pass body.Message to your LLM.
	_ = json.NewEncoder(w).Encode(map[string]string{"reply": "..."})
}

func main() {
	http.HandleFunc("/chat", chat)
	log.Fatal(http.ListenAndServe(":3000", nil))
}

func must[T any](v T, err error) T {
	if err != nil {
		log.Fatal(err)
	}
	return v
}

Pass r.Context() to Protect (as the example does) so the call honors client disconnects.

Call aj.Protect inside each handler — once per request. Avoid wrapping it in generic net/http middleware that runs on every path (including static assets); you lose the ability to apply per-route rules and risk double-counting traffic.

Features

Feature Request (NewClient) Guard (NewGuardClient)
Rate Limiting
Prompt Injection Detection
Sensitive Information Detection
Bot Protection
Shield WAF
Email Validation
Request Filters
IP Analysis
Custom Rules
  • 🔒 Prompt Injection Detection — detect and block prompt injection attacks before they reach your LLM.
  • 🤖 Bot Protection — stop scrapers, credential stuffers, and AI crawlers from abusing your endpoints.
  • 🛑 Rate Limiting — token bucket, fixed window, and sliding window algorithms; model AI token budgets per user.
  • 🛡️ Shield WAF — protect against SQL injection, XSS, and other common web attacks.
  • 📧 Email Validation — block disposable, invalid, and undeliverable addresses at signup.
  • 🎯 Request Filters — expression-based rules on IP, path, headers, and custom fields.
  • 🌐 IP Analysis — geolocation, ASN, VPN, proxy, Tor, and hosting detection included with every request.
  • 🧩 Arcjet Guard — lower-level API for AI agent tool calls and background tasks where there is no HTTP request.

Which features do I need?

If your app has... Recommended features
LLM / AI chat endpoints Prompt injection + token bucket rate limit + bot protection + shield
AI agent tool calls Arcjet Guard — rate limiting + prompt injection + custom rules
MCP servers Arcjet Guard — tool calls run over stdio/SSE, not HTTP, so use guard rules at each tool call site
Background jobs/workers Arcjet Guard — no HTTP request at the protection site
Public API Rate limiting + bot protection + shield
Signup / login forms Email validation + bot protection + rate limiting (or signup protection)
Internal / admin routes Shield + request filters (country, VPN/proxy blocking)
Any web application Shield + bot protection (good baseline for all apps)

All features can be combined in a single Arcjet client. Rules are evaluated together — if any rule denies the request, decision.IsDenied() returns true. Use arcjet.ModeDryRun on individual rules to test them before enforcing.

Installation

go get github.com/arcjet/arcjet-go

The SDK requires Go 1.25 or later.

Prompt injection detection

Detect and block prompt injection attacks — attempts by users to hijack your LLM's behavior through crafted input — before they reach your model.

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: []arcjet.Rule{
		arcjet.DetectPromptInjection(arcjet.PromptInjectionOptions{
			Mode: arcjet.ModeLive,
		}),
	},
})
if err != nil {
	return err
}

decision, err := aj.Protect(
	r.Context(),
	r,
	arcjet.WithDetectPromptInjectionMessage(body.Message),
)
if err != nil {
	// Fails open — log and continue.
	return err
}

if decision.IsDenied() {
	http.Error(w, "Prompt injection detected", http.StatusBadRequest)
	return
}

// Safe to pass body.Message to your LLM.

See the Prompt Injection docs for more details.

Bot protection

Manage traffic from automated clients. Block scrapers, credential stuffers, and AI crawlers, while allowing legitimate bots like search engines and monitors.

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: []arcjet.Rule{
		arcjet.DetectBot(arcjet.BotOptions{
			Mode: arcjet.ModeLive,
			Allow: []string{
				arcjet.BotCategorySearchEngine, // Google, Bing, etc.
				// arcjet.BotCategoryMonitor,    // Uptime monitoring
				// arcjet.BotCategoryPreview,    // Link previews (Slack, Discord)
				// "OPENAI_CRAWLER_SEARCH",      // Allow a specific bot by name
			},
		}),
	},
})
if err != nil {
	return err
}

decision, err := aj.Protect(r.Context(), r)
if err != nil {
	return err
}

if decision.IsDenied() {
	http.Error(w, "Bot detected", http.StatusForbidden)
	return
}

if decision.IsSpoofedBot() {
	http.Error(w, "Spoofed bot", http.StatusForbidden)
	return
}

Bot categories

Configure rules using categories or specific bot identifiers:

arcjet.DetectBot(arcjet.BotOptions{
	Mode: arcjet.ModeLive,
	Allow: []string{
		arcjet.BotCategorySearchEngine,
		"OPENAI_CRAWLER_SEARCH",
	},
})

Exported constants cover all built-in categories: arcjet.BotCategoryAcademic, BotCategoryAdvertising, BotCategoryAI, BotCategoryAmazon, BotCategoryArchive, BotCategoryBotnet, BotCategoryFeedFetcher, BotCategoryGoogle, BotCategoryMeta, BotCategoryMicrosoft, BotCategoryMonitor, BotCategoryOptimizer, BotCategoryPreview, BotCategoryProgrammatic, BotCategorySearchEngine, BotCategorySlack, BotCategorySocial, BotCategoryTool, BotCategoryUnknown, BotCategoryVercel, BotCategoryYahoo. Plain strings still work (e.g. "CATEGORY:AI" or "OPENAI_CRAWLER_SEARCH" for specific bots by name) — the constants exist for autocomplete and to catch typos at compile time.

If you specify an allow list, all other bots are denied. An empty allow list blocks all bots. The reverse applies for deny lists.

Verified vs. spoofed bots

Bots claiming to be well-known crawlers (e.g. Googlebot) are verified against their known IP ranges. Use decision.IsSpoofedBot() to check:

if decision.IsSpoofedBot() {
	http.Error(w, "Spoofed bot", http.StatusForbidden)
	return
}

decision.IsVerifiedBot() reports the opposite — a crawler whose IP matched its published ranges — which you may want to allow even when other signals would deny. decision.IsMissingUserAgent() reports a request a bot rule denied for having no User-Agent header, a common sign of an automated client.

See the Bot Protection docs for more details.

Rate limiting

Limit request rates per IP, user, or any custom characteristic. Arcjet supports token bucket, fixed window, and sliding window algorithms. Token buckets are ideal for controlling AI token budgets — set Capacity to the max tokens a user can spend, RefillRate to how many tokens are restored per Interval, and deduct tokens per request via arcjet.WithRequested(n). The Interval accepts a time.Duration. Use Characteristics to track limits per user instead of per IP.

Token bucket (recommended for AI)

Rate limits track by IP address by default. To track per user, declare the key name in Characteristics on the rule, then pass the actual value via arcjet.WithCharacteristics at call time:

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: []arcjet.Rule{
		arcjet.TokenBucket(arcjet.TokenBucketOptions{
			Mode:            arcjet.ModeLive,
			Characteristics: []string{"userId"}, // or omit for IP-based
			RefillRate:      100,                // tokens added per interval
			Interval:        time.Minute,        // interval duration
			Capacity:        1000,               // maximum tokens per bucket
		}),
	},
})

decision, err := aj.Protect(
	r.Context(),
	r,
	arcjet.WithRequested(5), // tokens consumed by this request
	arcjet.WithCharacteristics(map[string]string{"userId": "user_123"}),
)

if decision.IsDenied() {
	http.Error(w, "Rate limited", http.StatusTooManyRequests)
	return
}

Put Characteristics on the specific rate-limit rule that needs it, not on the global client — that way different rules can key by different things.

Fixed window

arcjet.FixedWindow(arcjet.FixedWindowOptions{
	Mode:        arcjet.ModeLive,
	Window:      time.Minute,
	MaxRequests: 100,
})

Sliding window

arcjet.SlidingWindow(arcjet.SlidingWindowOptions{
	Mode:        arcjet.ModeLive,
	Interval:    time.Minute,
	MaxRequests: 100,
})

See the Rate Limiting docs for more details.

Rate limit response headers

Call arcjet.SetRateLimitHeaders(w, decision) to advertise the limit to clients using the IETF RateLimit header fields for HTTP (RateLimit and RateLimit-Policy). It is a no-op when the decision carries no rate limit, so you can call it unconditionally:

decision, _ := aj.Protect(r.Context(), r)
arcjet.SetRateLimitHeaders(w, decision)
if decision.IsDenied() {
	http.Error(w, "Rate limited", http.StatusTooManyRequests)
	return
}

Protecting a signup form

arcjet.ProtectSignup bundles the rules commonly used on a signup form — a sliding-window rate limit, bot detection, and email validation — into one slice you can hand to Config.Rules:

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: arcjet.ProtectSignup(arcjet.ProtectSignupOptions{
		RateLimit: arcjet.SlidingWindowOptions{Mode: arcjet.ModeLive, Interval: time.Hour, MaxRequests: 5},
		Bots:      arcjet.BotOptions{Mode: arcjet.ModeLive, Allow: nil}, // block all bots
		Email:     arcjet.EmailOptions{Mode: arcjet.ModeLive, Deny: []arcjet.EmailType{arcjet.EmailTypeDisposable, arcjet.EmailTypeInvalid, arcjet.EmailTypeNoMXRecords}},
	}),
})

The returned rules can be combined with others using append.

Sensitive information detection

Detect and block personally identifiable information — emails, phone numbers, IP addresses, and credit card numbers — in text you pass to Protect. Detection runs locally via the bundled WebAssembly analyzer (the same arcjet_analyze_js_req component used by the JavaScript and Python SDKs), so the scanned text never leaves the SDK. Pass the text to scan with arcjet.WithSensitiveInfoValue:

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: []arcjet.Rule{
		arcjet.SensitiveInfo(arcjet.SensitiveInfoOptions{
			Mode: arcjet.ModeLive,
			Deny: []arcjet.EntityType{
				arcjet.SensitiveInfoEmail,
				arcjet.SensitiveInfoCreditCardNumber,
			},
		}),
	},
})

decision, err := aj.Protect(
	r.Context(),
	r,
	arcjet.WithSensitiveInfoValue("User input to scan"),
)
if decision.Reason.IsSensitiveInfo() {
	http.Error(w, "Sensitive information detected", http.StatusBadRequest)
	return
}

Allow and Deny are mutually exclusive: Deny blocks the listed entity types, while Allow blocks every type except those listed. The built-in entity types are SensitiveInfoEmail, SensitiveInfoPhoneNumber, SensitiveInfoIPAddress, and SensitiveInfoCreditCardNumber.

To detect custom entities, set Config.SensitiveInfoDetect. It receives the tokenized text and returns one EntityType per token (empty leaves a token unclassified); the returned label can be any custom string you then list in Allow/Deny:

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	SensitiveInfoDetect: func(ctx context.Context, tokens []string) []arcjet.EntityType {
		out := make([]arcjet.EntityType, len(tokens))
		for i, tok := range tokens {
			if strings.HasPrefix(tok, "sk-") {
				out[i] = "API_KEY"
			}
		}
		return out
	},
	Rules: []arcjet.Rule{
		arcjet.SensitiveInfo(arcjet.SensitiveInfoOptions{
			Mode: arcjet.ModeLive,
			Deny: []arcjet.EntityType{"API_KEY"},
		}),
	},
})

To redact rather than block sensitive information, see Redacting sensitive information below.

See the Sensitive Information docs for more details.

Redacting sensitive information

The github.com/arcjet/arcjet-go/redact package detects and redacts sensitive information — emails, phone numbers, IP addresses, credit card numbers, and custom entities — entirely in-process. The text is never sent to Arcjet, which makes it suitable for scrubbing prompts before they reach a third-party LLM and restoring the values in the response. It runs the same WebAssembly component as @arcjet/redact and arcjet.redact, so all three SDKs redact identically.

import "github.com/arcjet/arcjet-go/redact"

r, err := redact.New(ctx, redact.Options{})
if err != nil {
	log.Fatal(err)
}
defer r.Close(ctx)

redacted, unredact, err := r.Redact(ctx, "Contact me at test@example.com")
// redacted == "Contact me at <Redacted email #0>"

// ...send `redacted` to the model, then restore the originals in the reply:
answer := unredact(modelResponse)

redact.New compiles the component once; reuse the Redactor across calls. Use Options.Entities to limit which entity types are redacted, and Options.Detect / Options.Replace to plug in custom detection and replacement logic. See the Redaction docs for more details.

Shield WAF

Protect against common web attacks including SQL injection, XSS, path traversal, and other OWASP Top 10 threats. No additional configuration needed — Shield analyzes request patterns automatically.

arcjet.Shield(arcjet.ShieldOptions{Mode: arcjet.ModeLive})

Always include Shield on the shared client as a base rule — it costs nothing to add and protects every route.

See the Shield docs for more details.

Email validation

Prevent users from signing up with disposable, invalid, or undeliverable email addresses. Deny types: DISPOSABLE, FREE, INVALID, NO_MX_RECORDS, NO_GRAVATAR.

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: []arcjet.Rule{
		arcjet.ValidateEmail(arcjet.EmailOptions{
			Mode: arcjet.ModeLive,
			Deny: []arcjet.EmailType{
				arcjet.EmailTypeDisposable,
				arcjet.EmailTypeInvalid,
				arcjet.EmailTypeNoMXRecords,
			},
		}),
	},
})

// Pass the email with each Protect call.
decision, err := aj.Protect(
	r.Context(),
	r,
	arcjet.WithEmail("user@example.com"),
)

See the Email Validation docs for more details.

Request filters

Filter requests using expression-based rules against request properties (IP address, headers, path, HTTP method, and custom local fields).

Block by country

Restrict access to specific countries — useful for licensing, compliance, or regional rollouts. The Allow list denies all countries not listed:

aj, err := arcjet.NewClient(arcjet.Config{
	Key: arcjetKey,
	Rules: []arcjet.Rule{
		// Allow only US traffic — all other countries are denied.
		arcjet.Filter(arcjet.FilterOptions{
			Mode:  arcjet.ModeLive,
			Allow: []string{`ip.src.country == "US"`},
		}),
	},
})

decision, err := aj.Protect(r.Context(), r)
if decision.IsDenied() {
	http.Error(w, "Access restricted in your region", http.StatusForbidden)
	return
}

To restrict to a specific state or province, combine country and region:

arcjet.Filter(arcjet.FilterOptions{
	Mode: arcjet.ModeLive,
	// Allow only California — useful for state-level compliance e.g. CCPA testing.
	Allow: []string{`ip.src.country == "US" && ip.src.region == "California"`},
})

Block VPN and proxy traffic

Prevent anonymized traffic from accessing sensitive endpoints — useful for fraud prevention, enforcing geo-restrictions, and reducing abuse:

arcjet.Filter(arcjet.FilterOptions{
	Mode: arcjet.ModeLive,
	Deny: []string{
		"ip.src.vpn",   // VPN services
		"ip.src.proxy", // Open proxies
		"ip.src.tor",   // Tor exit nodes
	},
})

For cases where you want to allow some anonymized traffic (e.g. Apple Private Relay) but still log or handle it differently, use decision.IP helpers after calling Protect:

decision, err := aj.Protect(r.Context(), r)
if err != nil {
	return err
}

if decision.IP.IsVPN || decision.IP.IsTor {
	http.Error(w, "VPN traffic not allowed", http.StatusForbidden)
	return
}
if decision.IP.IsRelay {
	// Privacy relay (e.g. Apple Private Relay) — lower risk than a VPN.
	// Allow through with custom handling.
}

Custom local fields

Pass arbitrary values from your application for use in filter expressions:

decision, err := aj.Protect(
	r.Context(),
	r,
	arcjet.WithFilterLocal(map[string]string{
		"userId": currentUser.ID,
		"plan":   currentUser.Plan,
	}),
)

These are then available as local["userId"] and local["plan"] in expressions:

arcjet.Filter(arcjet.FilterOptions{
	Mode: arcjet.ModeLive,
	Deny: []string{`local["plan"] == "free" && ip.src.country != "US"`},
})

WithFilterLocal values stay local — they are evaluated by the embedded WebAssembly runtime and are never sent to Arcjet Cloud.

See the Request Filters docs, IP Geolocation blueprint, and VPN/Proxy Detection blueprint for more details.

IP analysis

Arcjet returns IP metadata with every decision — no extra API calls needed.

if decision.IP.IsHosting {
	// Likely a cloud/hosting provider — often suspicious for bots.
	http.Error(w, "Hosting IP blocked", http.StatusForbidden)
	return
}

if decision.IP.IsVPN || decision.IP.IsProxy || decision.IP.IsTor {
	// Apply your policy for anonymized traffic.
}

ip := decision.IP
log.Println(ip.City, ip.CountryName) // geolocation
log.Println(ip.ASN, ip.ASNName)      // ASN / network
log.Println(ip.IsVPN, ip.IsHosting)  // reputation

Available fields include geolocation (Latitude, Longitude, City, Region, Country, Continent), network (ASN, ASNName, ASNDomain, ASNType, ASNCountry), and reputation (IsVPN, IsProxy, IsTor, IsHosting, IsRelay).

Arcjet Guard

arcjet.NewGuardClient is a lower-level API designed for AI agent tool calls and background tasks where there is no HTTP request object. It gives you fine-grained, per-call control over rate limiting, prompt injection detection, sensitive information detection (via GuardSensitiveInfo), and custom rules.

How it differs from NewClient

NewClient (request protection) NewGuardClient (guard)
Designed for HTTP request protection AI agent tool calls, background jobs
Request object Required (Protect(ctx, r, ...)) Not needed
Rule binding Rules configured once, input via Protect options Rules configured once, bound with input per invocation
Rate limit key IP or WithCharacteristics Explicit key string (SHA-256 hashed before sending)
Custom rules Not supported GuardCustom

Installation

Guard is part of the same github.com/arcjet/arcjet-go module — no extra install required.

Quick start

Declare the guard client and each rule once at package scope. Call guard.Guard at each specific operation with a hardcoded Label so the dashboard groups calls by what they actually are.

package agent

import (
	"context"
	"errors"
	"fmt"
	"os"
	"time"

	"github.com/arcjet/arcjet-go"
)

// Single guard client — reused across calls.
var guard = must(arcjet.NewGuardClient(arcjet.GuardConfig{
	Key: os.Getenv("ARCJET_KEY"),
}))

// Rules configured once. Each rule's Bucket groups its counters server-side.
var (
	userLimit = must(arcjet.GuardTokenBucket(arcjet.GuardTokenBucketOptions{
		Mode:       arcjet.ModeLive,
		RefillRate: 100,
		Interval:   time.Minute,
		Capacity:   1000,
		Bucket:     "agent.user-tokens", // distinct from any Label
	}))
	promptScan = must(arcjet.GuardPromptInjection(arcjet.GuardPromptInjectionOptions{Mode: arcjet.ModeLive}))
)

// GetWeather is an agent tool. Guard at the tool function (or at the
// dispatch arm right before calling it) — never in a generic
// handleToolCall(name, args) wrapper with an interpolated label.
func GetWeather(ctx context.Context, userID, message string) error {
	decision, err := guard.Guard(ctx, arcjet.GuardRequest{
		Label: "tools.get_weather", // hardcoded string, not fmt.Sprintf
		Metadata: map[string]string{
			"userId": userID,
		},
		Rules: []arcjet.GuardRuleInput{
			userLimit.Key(userID, 1),
			promptScan.Text(message),
		},
	})
	if err != nil {
		// Guard fails open — log and continue.
		return nil
	}
	if decision.IsDenied() {
		// Use the per-rule accessor to recover details — rate limit reset
		// time, prompt injection signal — so the caller knows WHY.
		if r := userLimit.DeniedResult(decision); r != nil {
			return fmt.Errorf("rate limited — resets at unix %d", r.ResetAtUnixSeconds)
		}
		if promptScan.DeniedResult(decision) != nil {
			return errors.New("input flagged as prompt injection")
		}
		return errors.New("blocked")
	}

	// ... do the work.
	return nil
}

Note: The Label argument should be a hardcoded string like "tools.get_weather", not fmt.Sprintf("tools.%s", name). Hardcoded labels stay greppable, and the dashboard groups by them. Interpolation produces a sea of distinct-looking entries instead of one bucket per operation.

Rate limiting

Token bucket, fixed window, and sliding window algorithms are available. Configure the rule once, then call .Key(key, requested) with a key and optional requested count for each invocation.

Every guard rate-limit rule requires an explicit Key at call time. There is no IP fallback — guard runs where there is no HTTP context. When there is no per-user context (e.g. a stdio MCP server or a single-tenant worker), pick a stable identifier such as the deployment name or "default" and add a comment explaining why.

Token bucket

userLimit, err := arcjet.GuardTokenBucket(arcjet.GuardTokenBucketOptions{
	Mode:       arcjet.ModeLive,
	RefillRate: 100,         // tokens added per interval
	Interval:   time.Minute, // refill interval
	Capacity:   1000,        // maximum bucket capacity
	Bucket:     "agent.user-tokens",
})

// At call time:
decision, err := guard.Guard(ctx, arcjet.GuardRequest{
	Label: "tools.get_weather",
	Rules: []arcjet.GuardRuleInput{userLimit.Key(userID, 5)},
})

Fixed window

teamLimit, err := arcjet.GuardFixedWindow(arcjet.GuardFixedWindowOptions{
	Mode:        arcjet.ModeLive,
	Window:      time.Hour,
	MaxRequests: 1000,
	Bucket:      "api.search",
})

decision, err := guard.Guard(ctx, arcjet.GuardRequest{
	Label: "api.search",
	Rules: []arcjet.GuardRuleInput{teamLimit.Key(teamID, 1)},
})

Sliding window

apiLimit, err := arcjet.GuardSlidingWindow(arcjet.GuardSlidingWindowOptions{
	Mode:        arcjet.ModeLive,
	Interval:    time.Minute,
	MaxRequests: 500,
	Bucket:      "api.query",
})

decision, err := guard.Guard(ctx, arcjet.GuardRequest{
	Label: "api.query",
	Rules: []arcjet.GuardRuleInput{apiLimit.Key(userID, 1)},
})

Prompt injection detection

Use on any untrusted text before it reaches a model or tool argument — and on tool call results when the tool fetches content from untrusted sources.

promptScan, err := arcjet.GuardPromptInjection(arcjet.GuardPromptInjectionOptions{Mode: arcjet.ModeLive})

decision, err := guard.Guard(ctx, arcjet.GuardRequest{
	Label: "tools.get_weather",
	Rules: []arcjet.GuardRuleInput{promptScan.Text(userMessage)},
})

if decision.IsDenied() && decision.Reason == arcjet.ReasonPromptInjection {
	return errors.New("prompt injection detected")
}

Custom rules

Define custom local rules with GuardCustom. The evaluation function runs in your process — Arcjet never executes it. The Input map you pass at call time is submitted to Arcjet alongside the function's Conclusion and any opaque Data, so the dashboard can show what the rule saw. Do not pass raw secrets or unhashed PII through Input; hash or redact first if the inputs are sensitive.

topicRule, err := arcjet.GuardCustom(arcjet.GuardCustomOptions{
	Mode:   arcjet.ModeLive,
	Config: map[string]string{"blocked_topic": "weapons"},
	Func: func(ctx context.Context, input map[string]string) (arcjet.GuardCustomResult, error) {
		if input["topic"] == "weapons" {
			return arcjet.GuardCustomResult{
				Conclusion: arcjet.ConclusionDeny,
				Data:       map[string]string{"matched": input["topic"]},
			}, nil
		}
		return arcjet.GuardCustomResult{Conclusion: arcjet.ConclusionAllow}, nil
	},
})

decision, err := guard.Guard(ctx, arcjet.GuardRequest{
	Label: "content",
	Rules: []arcjet.GuardRuleInput{
		topicRule.Input(map[string]string{"topic": userTopic}),
	},
})

Per-rule results

decision.Results contains one entry per rule invocation, with typed result details for inspection:

for _, result := range decision.Results {
	switch {
	case result.TokenBucket != nil:
		log.Printf("rate limit: %d / %d remaining; resets at %d",
			result.TokenBucket.RemainingTokens,
			result.TokenBucket.MaxTokens,
			result.TokenBucket.ResetAtUnixSeconds)
	case result.PromptInjection != nil:
		log.Printf("prompt injection detected=%v", result.PromptInjection.Detected)
	case result.LocalSensitiveInfo != nil:
		log.Printf("sensitive info detected=%v types=%v",
			result.LocalSensitiveInfo.Detected,
			result.LocalSensitiveInfo.DetectedEntityTypes)
	}
	if result.IsDenied() {
		log.Printf("denied by %s", result.Reason)
	}
}

Decision API

decision, err := guard.Guard(ctx, arcjet.GuardRequest{
	Label: "tools.get_weather",
	Rules: []arcjet.GuardRuleInput{userLimit.Key(userID, 5)},
})

// Layer 1: conclusion and reason.
decision.Conclusion // arcjet.ConclusionAllow or arcjet.ConclusionDeny
decision.Reason     // arcjet.ReasonRateLimit, ReasonPromptInjection, etc.

// Layer 2: error detection.
decision.IsErrored() // true if any rule errored or the server reported diagnostics

// Layer 3: per-rule results (see "Per-rule results" above).
for _, result := range decision.Results {
	log.Println(result.Type, result.Conclusion)
}

Guard parameter reference

Field Type Description
Rules []arcjet.GuardRuleInput Bound rule inputs (required)
Label string Hardcoded label identifying this guard call (required)
Metadata map[string]string Optional key-value metadata recorded in the dashboard

DRY_RUN mode

All guard rules accept a Mode parameter. Use arcjet.ModeDryRun to evaluate rules without blocking:

userLimit, err := arcjet.GuardTokenBucket(arcjet.GuardTokenBucketOptions{
	Mode:       arcjet.ModeDryRun,
	RefillRate: 10,
	Interval:   time.Minute,
	Capacity:   100,
	Bucket:     "agent.user-tokens",
})

Best practices

Single-instance pattern

Create one Arcjet client at startup and reuse it across all handlers:

// Good — one instance, created once at package scope.
var aj = must(arcjet.NewClient(arcjet.Config{Key: arcjetKey, Rules: []arcjet.Rule{...}}))

// Bad — new client per request wastes resources and creates a new HTTP/2
// connection each time.
func handler(w http.ResponseWriter, r *http.Request) {
	aj, _ := arcjet.NewClient(arcjet.Config{...}) // don't do this
}

Shared client in its own package

For larger projects, put the client in its own package (e.g. internal/security/arcjet.go) and import it from handlers. Always include Shield as a base rule, then layer route-specific rules with WithRule:

// internal/security/arcjet.go
package security

import (
	"os"

	"github.com/arcjet/arcjet-go"
)

var Client = must(arcjet.NewClient(arcjet.Config{
	Key: os.Getenv("ARCJET_KEY"),
	Rules: []arcjet.Rule{
		arcjet.Shield(arcjet.ShieldOptions{Mode: arcjet.ModeLive}),
		arcjet.DetectBot(arcjet.BotOptions{Mode: arcjet.ModeLive, Allow: []string{}}),
	},
}))
// internal/http/chat.go
package http

import (
	"log"
	"net/http"
	"time"

	"github.com/arcjet/arcjet-go"
	"example.com/app/internal/security"
)

// Layer per-route rules without mutating the shared client. WithRule
// validates and pre-builds the rule's wire form, so it returns an error
// for misconfigured rules — keep the call at package scope so a bad rule
// fails at startup instead of on the first request.
var chatClient = must(security.Client.WithRule(arcjet.TokenBucket(arcjet.TokenBucketOptions{
	Mode:            arcjet.ModeLive,
	Characteristics: []string{"userId"},
	RefillRate:      100,
	Interval:        time.Minute,
	Capacity:        1000,
})))

func must[T any](v T, err error) T {
	if err != nil {
		log.Fatal(err)
	}
	return v
}

WithRule returns a copy — the original client is left unchanged, so the same base instance can be specialised for many routes.

DRY_RUN mode for testing

Use arcjet.ModeDryRun to test rules without blocking traffic. Decisions are logged but requests are allowed through:

arcjet.DetectBot(arcjet.BotOptions{Mode: arcjet.ModeDryRun, Allow: []string{}})
arcjet.TokenBucket(arcjet.TokenBucketOptions{
	Mode:       arcjet.ModeDryRun,
	RefillRate: 5,
	Interval:   10 * time.Second,
	Capacity:   10,
})

Proxy configuration

When running behind a load balancer or reverse proxy, configure trusted IPs so Arcjet resolves the real client IP from X-Forwarded-For:

aj, err := arcjet.NewClient(arcjet.Config{
	Key:     arcjetKey,
	Rules:   []arcjet.Rule{...},
	Proxies: []string{"10.0.0.0/8", "192.168.0.1"},
})

Hosting platform

When deployed on a managed hosting platform, Arcjet reads the client IP from that platform's signed headers. Fly.io, Vercel, Render, Firebase, Railway, and Cloudflare Pages are auto-detected from their environment variables. If your service runs behind a platform that isn't auto-detected — most importantly a Go origin behind the Cloudflare CDN, which doesn't set CF_PAGES — set Config.Platform explicitly so Arcjet trusts the platform header (e.g. CF-Connecting-IP) instead of guessing:

aj, err := arcjet.NewClient(arcjet.Config{
	Key:      arcjetKey,
	Rules:    []arcjet.Rule{...},
	Platform: arcjet.PlatformCloudflare,
})

NewClient returns ErrInvalidPlatform for an unrecognized value.

Protect parameter reference

All options are optional and passed alongside the *http.Request:

Option Used by
WithRequested(int) Token bucket rate limit
WithCharacteristic(key, value string) Rate limiting — single key/value
WithCharacteristics(map[string]string) Rate limiting (values for keys declared in rules)
WithDetectPromptInjectionMessage(string) Prompt injection detection
WithSensitiveInfoValue(string) Sensitive information detection (text scanned locally)
WithEmail(string) Email validation
WithFilterLocal(map[string]string) Request filters using local["field"] expressions
WithIPSrc(string) Manual IP override (advanced)
WithBody([]byte) Request body override
WithExtra(map[string]string) Additional fields sent to Arcjet

Decision response

decision, err := aj.Protect(r.Context(), r)

// Top-level checks.
decision.IsDenied()    // true if any LIVE rule denied the request
decision.IsAllowed()   // true if all rules allowed the request
decision.IsErrored()   // true if Arcjet encountered an error (fails open)

// Branch on reason for actionable error responses. Only branch on reasons that
// produce a different response — a SHIELD arm returning 403 when the default
// already returns 403 is dead code.
switch {
case decision.Reason.IsRateLimit():
	log.Println(decision.Reason.RateLimit.Remaining)
case decision.Reason.IsBot():
	log.Println(decision.Reason.Bot.Denied)
}

// Per-rule results.
for _, result := range decision.Results {
	log.Println(result.Reason.Type, result.Conclusion)
}

Error handling

Arcjet is designed to fail open — if the service is unavailable, requests are allowed through. Check for errors explicitly if your use case requires it:

decision, err := aj.Protect(r.Context(), r)
if err != nil {
	// Arcjet service error — fail open or apply fallback policy.
	log.Printf("arcjet: %v", err)
} else if decision.IsDenied() {
	http.Error(w, "Denied", http.StatusForbidden)
	return
}

Configuration and validation errors wrap exported sentinels so they can be detected with errors.Is:

aj, err := arcjet.NewClient(arcjet.Config{Key: ""})
if errors.Is(err, arcjet.ErrMissingKey) {
	log.Fatal("set ARCJET_KEY in your environment")
}

Available sentinels: ErrMissingKey, ErrNilClient, ErrNilRequest, ErrNilRule, ErrInvalidMode, ErrAllowDenyConflict, ErrInvalidProxy, ErrInvalidLabel, ErrInvalidRateLimit, ErrEmptyKey, ErrMissingFunc, ErrInvalidWasm, ErrWasmClosed, ErrWasmExportNotFound, ErrEmptyResponse.

Remote and per-rule errors are surfaced as ArcjetError values with a Code field. Match a specific server error code with errors.Is:

if errors.Is(err, arcjet.ArcjetError{Code: "AJ1100"}) {
	// handle a specific Arcjet error code
}

Decision.Err() and GuardDecision.Err() return the underlying ArcjetError (or nil) when the decision errored — useful for bubbling Arcjet errors out of helpers.

Verify decisions

After wiring up protection, confirm it is actually firing. There is no shortcut to seeing a real decision — trigger one, then check the platform.

  1. Build and start the app (go build ./... && ./your-app).
  2. Trigger a real request, e.g. curl http://localhost:3000/chat. To trip a rate limit, loop the call:
    for i in {1..50}; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/chat; done
    For guard calls, invoke the protected function directly (go run ./cmd/agent-smoketest) rather than trying to curl something — there is no HTTP surface.
  3. Confirm the decision via the Arcjet CLI:
    arcjet requests list --site-id <id>   # request protection
    arcjet guards list --site-id <id>     # guard
    arcjet requests explain --site-id <id> --request-id <id>

The dashboard at app.arcjet.com shows the same data with filtering and history.

Gotchas

  • Wrong client: NewClient is for HTTP routes; NewGuardClient is for non-HTTP code (tool calls, MCP servers, queue workers, background jobs). Using the wrong one is the most common mistake — MCP "servers" don't receive HTTP requests, so they use NewGuardClient.
  • Wrong placement: Protect belongs inside each handler, not in generic middleware that wraps every request including static assets.
  • Wrong layer for Guard: don't put guard.Guard in a handleToolCall(name, args) dispatcher. Put it inside each specific tool function (or the dispatch arm right before the call) so the Label and Metadata can be hardcoded.
  • Interpolated labels: Label: fmt.Sprintf("tools.%s", name) defeats the dashboard grouping. Use a hardcoded string per tool.
  • Double-counting: calling Protect or Guard multiple times for the same operation counts against rate limits multiple times.
  • Hardcoded keys: never hardcode ARCJET_KEY — read it from the environment. Don't commit it to source.

Support

This repository follows the Arcjet Support Policy.

Security

This repository follows the Arcjet Security Policy.

License

Licensed under the Apache License, Version 2.0.

About

Arcjet Go SDK. Stop bots and automated attacks from burning your AI budget, leaking data, or misusing tools with Arcjet's AI security building blocks.

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Contributors