Arcjet Go SDK reference
The Arcjet Go SDK protects net/http handlers and non-HTTP operations such as
AI tool calls, MCP handlers, queue workers, and background jobs. It works with
the standard library and routers that expose *http.Request.
The Go SDK requires Go 1.25 or later.
Client IP address detection
Section titled “Client IP address detection”Install
Section titled “Install”Add the SDK with go get, which records a compatible version in go.mod:
go get github.com/arcjet/arcjet-go@latestThen set ARCJET_KEY in your environment. You can get a key from the
Arcjet Console, the
Arcjet CLI, or the
MCP server.
Environment variables
Section titled “Environment variables”NewClient and NewGuardClient read ARCJET_KEY when Key is empty. An
explicit Key takes precedence, so Key: os.Getenv("ARCJET_KEY") also works.
The other SDKs handle the key differently. The JavaScript Guard SDK doesn’t read environment variables, and the Python SDK requires an explicit key for both Guard and Protect.
The Go SDK doesn’t read
ARCJET_ENV
and has no development-mode switch. Set Config.Platform on the HTTP
client to select a hosting platform. To override the detected client IP on
a request, pass WithIPSrc to Protect.
For more information, see Concepts: Environment variables.
Choose an API
Section titled “Choose an API”| API | Use it for |
|---|---|
arcjet.NewClient and Protect | HTTP handlers with a *http.Request |
arcjet.NewGuardClient and Guard | Tool calls, MCP handlers, queues, background jobs, and other operations without an HTTP request |
arcjet.GuardAction | The same operations, when you want the helper to run the function only if policy allows it |
github.com/arcjet/arcjet-go/agentframework | Microsoft Agent Framework tools and agent runs |
Create each client once at package scope and reuse it, rather than creating a client for every request or operation.
Protect HTTP handlers
Section titled “Protect HTTP handlers”Configure shared rules on an arcjet.Client, then call Protect once inside
each handler:
package main
import ( "log" "net/http" "os" "time"
"github.com/arcjet/arcjet-go")
var aj = 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{}, }), arcjet.TokenBucket(arcjet.TokenBucketOptions{ Mode: arcjet.ModeLive, Characteristics: []string{"userId"}, RefillRate: 10, Interval: time.Minute, Capacity: 10, }), },}))
func handler(w http.ResponseWriter, r *http.Request) { userID := "user_123" // Replace with the authenticated user ID.
decision, err := aj.Protect( r.Context(), r, arcjet.WithCharacteristics(map[string]string{"userId": userID}), arcjet.WithRequested(1), ) if err != nil { // Fail-open: ERROR decision plus err. Log it and continue. 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 }
w.WriteHeader(http.StatusNoContent)}
func must[T any](value T, err error) T { if err != nil { panic(err) } return value}To add a rule for one route, derive a client with client.WithRule(...).
Arcjet evaluates Protect rules in a fixed order, not the order that you declare
them in. NewClient and WithRule sort them once: SensitiveInfo, Filter,
Shield, rate-limit rules (TokenBucket, FixedWindow, SlidingWindow),
DetectBot, ValidateEmail, then DetectPromptInjection. Rules with the
same priority keep their declaration order. The first denial from a
ModeLive rule stops evaluation, so later rules don’t run.
SensitiveInfo runs first, so Arcjet can deny sensitive text before another
rule forwards the payload.
ProtectSignup bundles a sliding-window rate limit, bot detection, and email
validation into one []Rule for a signup form:
aj, err := arcjet.NewClient(arcjet.Config{ Key: os.Getenv("ARCJET_KEY"), Rules: arcjet.ProtectSignup(arcjet.ProtectSignupOptions{ RateLimit: arcjet.SlidingWindowOptions{ Mode: arcjet.ModeLive, Interval: time.Hour, MaxRequests: 5, }, Bots: arcjet.BotOptions{Mode: arcjet.ModeLive, Allow: nil}, Email: arcjet.EmailOptions{ Mode: arcjet.ModeLive, Deny: []arcjet.EmailType{ arcjet.EmailTypeDisposable, arcjet.EmailTypeInvalid, arcjet.EmailTypeNoMXRecords, }, }, }),})Call arcjet.SetRateLimitHeaders(w, decision) after Protect to advertise
the limit with RateLimit and RateLimit-Policy. It does nothing when the
decision has no rate limit, so you can call it on every request.
For more information, see Signup form protection and Rate limit headers.
Pass dynamic inputs with the following Protect options:
WithCharacteristics(...)for rate-limit keys such as a user or tenant ID.WithRequested(...)for the number of tokens a token bucket consumes.WithDetectPromptInjectionMessage(...)for untrusted text.WithSensitiveInfoValue(...)for text to scan for sensitive information.WithCorrelationID(...)to correlate a request decision with related work.WithMetadata(...)for nested JSON attached to the decision. See Metadata.WithIPSrc(...)to override the automatically detected client IP address.
If your application runs behind a trusted reverse proxy, list the proxy
addresses or CIDR ranges in Config.Proxies so Arcjet sees the real client IP.
Timeouts
Section titled “Timeouts”When the incoming context has no deadline, Protect and ProtectDetails
apply one of 2 seconds. A client with an email rule doubles that to
4 seconds, including a client that also has a prompt injection rule. A
prompt injection rule needs at least 1 second, which the 2-second default
already allows. Arcjet keeps a deadline that you set, without shortening or
replacing it.
// The context has no deadline, so the SDK applies 2 seconds.decision, err := aj.Protect(context.Background(), r)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)defer cancel()// The caller deadline is kept.decision, err = aj.Protect(ctx, r)Override the client IP
Section titled “Override the client IP”Arcjet normally detects the client IP address from the request. If your
application has already determined the client IP from a trusted source, pass
WithIPSrc to Protect:
ipSrc := getClientIPFromTrustedSource(r)decision, err := aj.Protect(r.Context(), r, arcjet.WithIPSrc(ipSrc))A non-empty value takes precedence over automatic detection. Arcjet rejects an
empty string and any malformed IPv4 or IPv6 address. ProtectDetails also
accepts WithIPSrc.
Caution: Arcjet checks that the value is a valid address, not where it came from. Pass a value from a trusted source, never a header that the client controls – otherwise a client can choose the IP address that fingerprinting, rate limiting, and other security checks use.
To inspect automatic detection without requesting a decision, call
ClientIPDetails:
details := aj.ClientIPDetails(r)log.Printf("ip=%s provenance=%s verified=%t header=%s", details.IP, details.Provenance, details.Verified, details.Header)Automatic detection returns direct, platform, trusted-proxy,
unverified-header, or none. Protection logs can also report manual when
you use WithIPSrc, or request when you supply ProtectDetails.IP. Debug
logs include the provenance as client_ip_provenance, alongside
client_ip_verified and client_ip_header. Verified means that the SDK tied
the address to the request path. It doesn’t confirm that your deployment is
configured correctly.
Sensitive information detection
Section titled “Sensitive information detection”Use SensitiveInfo on the HTTP client and pass the text to scan with
WithSensitiveInfoValue. Detection runs on-device with the
github.com/arcjet/arcjet-go/sensitiveinfo/rampart
module. Install it with the SDK:
go get github.com/arcjet/arcjet-go/sensitiveinfo/rampartCreate the backend once at startup with rampart.New, reuse it, and set it as
Backend. Allow and Deny are mutually exclusive.
backend, err := rampart.New(rampart.Options{})if err != nil { log.Fatal(err)}
aj, err := arcjet.NewClient(arcjet.Config{ Key: os.Getenv("ARCJET_KEY"), Rules: []arcjet.Rule{ arcjet.SensitiveInfo(arcjet.SensitiveInfoOptions{ Mode: arcjet.ModeLive, Deny: []arcjet.EntityType{ arcjet.SensitiveInfoGivenName, arcjet.SensitiveInfoSurname, arcjet.SensitiveInfoEmail, arcjet.SensitiveInfoSSN, }, Backend: backend, }), },})rampart.Entities() returns every type that the backend can detect. Only the
model detects phone numbers, because their digit shape overlaps with financial
and government identifiers.
Rampart scans up to MaxInputChars characters per call, 4,096 by default,
which keeps on-device detection fast. A live SensitiveInfo or
GuardSensitiveInfo rule denies longer input without scanning it, so nothing
past the limit goes through unchecked. With Protect, the denial has an error
reason, so decision.IsDenied() and decision.Reason.IsError() are both true.
To accept longer text, raise MaxInputChars.
To detect your own entity types, add a recognizer to Options.Recognizers. A
recognizer receives the text and returns the spans it matched, with byte
offsets:
var ibanPattern = regexp.MustCompile(`\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b`)
func detectIBAN(text string) []rampart.DetectedSpan { var spans []rampart.DetectedSpan for _, loc := range ibanPattern.FindAllStringIndex(text, -1) { spans = append(spans, rampart.DetectedSpan{ Start: loc[0], End: loc[1], Type: arcjet.SensitiveInfoBankAccount, }) } return spans}
backend, err := rampart.New(rampart.Options{ // Keep the default recognizers and add your own. Recognizers: append(rampart.DefaultRecognizers, detectIBAN),})On a Guard client, pass the same Backend in GuardSensitiveInfoOptions.
For more information about scanning request text, see Sensitive information detection.
Redact
Section titled “Redact”The github.com/arcjet/arcjet-go/redact package detects and redacts emails,
phone numbers, IP addresses, credit card numbers, and custom entities
in-process, so the text stays in your application and isn’t sent to Arcjet.
It runs the same WebAssembly component as @arcjet/redact.
r, err := redact.New(ctx, redact.Options{ Entities: []string{redact.EntityEmail},})if err != nil { log.Fatal(err)}defer r.Close(ctx)
redacted, unredact, err := r.Redact(ctx, "Contact me at test@example.com")answer := unredact(modelResponse)Create the Redactor once and reuse it. For more information about redacting
strings, see
Arcjet Redact.
Error handling
Section titled “Error handling”Arcjet fails open by default, so a network or service issue doesn’t block your traffic. You choose which routes fail closed instead.
When a transport failure prevents a decision, Protect and ProtectDetails
return a Decision with an ERROR conclusion, together with err:
decision.IsAllowed()is true. Arcjet treats anERRORconclusion as allowed.decision.IsErrored()is true. Use it to distinguish a real allow from a fail-open error.decision.IsDenied()is false.
If the client or request is nil, Protect returns the zero Decision,
together with err. The zero value has an empty Conclusion, so both
IsAllowed() and IsDenied() are false.
To fail open, log err and deny only when IsDenied() is true:
decision, err := aj.Protect(r.Context(), r)if err != nil { log.Printf("arcjet: %v", err)} else if decision.IsDenied() { http.Error(w, "denied", http.StatusForbidden) return}If you check decision.IsAllowed() instead, an ERROR decision allows the
request, but the zero Decision from a nil client or request doesn’t. To fail
closed, for example on a route that sends text to an AI model, deny the request
when err is set or decision.IsErrored() is true.
Guard returns a usable decision even when a transport failure occurs. For more information, see Errors and warnings.
Guard non-HTTP operations
Section titled “Guard non-HTTP operations”Guard protects operations that have no *http.Request, such as tool calls and
background jobs. Configure the client and reusable rules at package scope, then
pass the inputs where the operation runs:
package main
import ( "context" "fmt" "os" "time"
"github.com/arcjet/arcjet-go")
var guard = must(arcjet.NewGuardClient(arcjet.GuardConfig{ Key: os.Getenv("ARCJET_KEY"),}))
var userLimit = must(arcjet.GuardTokenBucket(arcjet.GuardTokenBucketOptions{ Mode: arcjet.ModeLive, // required Label: "tools.weather.limit", Bucket: "tools-weather", RefillRate: 10, Interval: time.Minute, Capacity: 10,}))
var promptScan = must(arcjet.GuardPromptInjection( arcjet.GuardPromptInjectionOptions{Mode: arcjet.ModeLive}, // required))
func getWeather(ctx context.Context, userID, prompt string) error { decision, err := guard.Guard(ctx, arcjet.GuardRequest{ Label: "tools.get-weather", CorrelationID: "trace_123", Metadata: arcjet.Metadata{ "user": map[string]any{"id": userID}, }, Rules: []arcjet.GuardRuleInput{ userLimit.Key(userID, 1), promptScan.Text(prompt), }, }) if err != nil { return err } if decision.IsDenied() { return fmt.Errorf("blocked: %s", decision.Reason) } return nil}
func must[T any](value T, err error) T { if err != nil { panic(err) } return value}Guard labels are slugs: ASCII letters, digits, dashes, underscores, and dots,
starting and ending with a letter or digit. They are case-sensitive, so
tools.getWeather and tools.getweather are different labels. Uppercase
letters need arcjet-go v1.2.0 or later. Use a stable, hardcoded label for
each operation and an explicit rate-limit key such as a user ID, session ID, or
API key.
Bucket on a rate-limit rule is a separate identifier from the Guard label.
It groups counters. For more information about rate limiting, see
Rate limiting.
The guard doesn’t run for a label that the server can’t match: the call
returns ALLOW, and HasFailedOpen() is false. If you build a label at
runtime, check it with ValidateGuardLabel. Capture still sends an event
with such a label, and adds the AJ1023 warning.
Every Guard rule constructor requires Mode, either ModeLive or
ModeDryRun, and returns ErrInvalidMode when it’s empty. Go can’t mark a
struct field as required, so the constructor returns an error rather than
defaulting to LIVE like JavaScript and Python Guard rules do. HTTP Protect
rules differ: they default an empty Mode to ModeDryRun.
If you omit Bucket, the SDK uses default-token-bucket,
default-fixed-window, or default-sliding-window. Set Bucket explicitly
so unrelated rules don’t share a counter.
Available Guard rules include rate limiting, prompt injection detection,
content moderation (GuardModerateContent), sensitive information detection,
and custom local rules.
To record what your application did after a Guard call, use Capture.
Captures give you visibility and never change a decision. For more information,
see Capture.
Guard an action
Section titled “Guard an action”Guard returns a decision for you to act on. GuardAction goes a step further
and runs your function only if the policy allows it, so the decision and the
action can’t drift apart. It fails closed by default, which suits actions with
real consequences, such as tool calls, jobs, and workers.
receipt, err := arcjet.GuardAction(ctx, guard, arcjet.GuardActionPolicy{ Action: "refund.issued", Actor: userID, Rules: []arcjet.GuardRuleInput{refundLimit.Key(userID, 1)}, Metadata: arcjet.SecurityMetadata{ User: userID, Reversibility: "irreversible", }.Metadata(),}, func(ctx context.Context) (Receipt, error) { return issueRefund(ctx, orderNumber)})
var denied *arcjet.GuardDeniedErrorvar unavailable *arcjet.GuardUnavailableErrorswitch {case errors.As(err, &denied): return fmt.Errorf("refund refused: %s", denied.Decision.Reason)case errors.As(err, &unavailable): return fmt.Errorf("refund not attempted: %w", unavailable)case err != nil: return err}Action is required, and the SDK sends it as the Guard label. Every call
reaches Guard, even one with no rules, because the server uses the label to
select a remote policy. To compute Actor, Inputs, and Rules when the call
runs, set Resolve, which replaces the static fields.
A DENY decision returns *GuardDeniedError and doesn’t run the function,
whatever you set OnGuardError to. Sometimes Arcjet can’t evaluate the policy,
because Guard returned an error, the decision failed open, or Resolve failed.
The default, OnGuardErrorDeny, then returns *GuardUnavailableError without
running the function. To run the function anyway, set OnGuardErrorAllow.
Each call records one capture event, named after Action, with an outcome
metadata value of success, degraded, denied, error, or unavailable.
When the caller’s context has no deadline, GuardAction limits its Guard call
to 2 seconds.
For Microsoft Agent Framework tools, the agent framework helpers place the same check around a tool and return a denial that the model can read instead of an error.
Correlate decisions
Section titled “Correlate decisions”A correlation ID links the decisions and capture events of one run. In the
Arcjet Console, a Sequence is the capture events that share a correlation ID,
so decisions alone don’t form a Sequence. GuardAction and the agent
framework helpers read the correlation ID from the context:
ctx = arcjet.ContextWithCorrelationID(ctx, conversationID)id, ok := arcjet.CorrelationIDFromContext(ctx)Guard and Capture don’t read the context, so pass CorrelationID to them
explicitly. The SDK ignores an empty ID, so passing through a missing value
doesn’t replace an ID that an outer layer already set. The SDK doesn’t generate
an ID for you.
Errors and warnings
Section titled “Errors and warnings”Guard keeps policy decisions separate from processing problems, so you can tell a denial from an error:
decision.IsDenied()reports a policy denial.decision.HasFailedOpen()reports that an error caused the decision to allow.decision.ErrorResults()returns errored rule results.- A configured rule’s
ErrorResult(decision)attributes an error to that rule. decision.Warningscontains diagnostics that do not change the conclusion.
Arcjet fails open by default, so a service issue doesn’t block your
application. For sensitive operations, choose the fallback yourself: deny when
HasFailedOpen() is true, or use GuardAction, which fails closed by default.
Content moderation
Section titled “Content moderation”GuardModerateContent checks text for harmful content before your application
acts on it:
moderation, err := arcjet.GuardModerateContent(arcjet.GuardModerateContentOptions{ Mode: arcjet.ModeLive, // required})
decision, err := guard.Guard(ctx, arcjet.GuardRequest{ Label: "tools.generate", Rules: []arcjet.GuardRuleInput{moderation.Text(userMessage)},})
// Deny harmful content, text over 128 KiB, and checks that Arcjet couldn't// complete. Arcjet moderates the first 60 KB and adds an AJ1142 warning to// decision.Warnings for longer text. To moderate all of it, split it up.if decision.IsDenied() || decision.HasFailedOpen() { return errors.New("content flagged by moderation")}
if result := moderation.Result(decision); result != nil && result.Billing != nil { fmt.Printf("charged %d %s\n", result.Billing.Count, result.Billing.Unit)}The result is a Detected verdict, plus optional Billing in text_units.
For more information, see Content moderation.
Capture
Section titled “Capture”Use Capture to record a fact about what your application did. Captures never
affect Guard or Protect conclusions and never set HasFailedOpen().
guard.Capture(arcjet.CaptureEvent{ Action: "refund.issued", CorrelationID: runID, DecisionID: decision.ID, Metadata: arcjet.Metadata{ "invoice": map[string]any{"id": "inv_123", "amount": 4200}, "refunded": true, },})Action is required, and the SDK drops an event without one. The optional
fields are CorrelationID, DecisionID, Metadata, and OccurredAt (zero
means now). Delivery is best effort: the SDK queues events in a bounded
in-memory queue, sends them in batches, and doesn’t retry a failed batch. To
send the last batch, call Flush or Close on shutdown:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)defer cancel()guard.Flush(ctx)For more information, see Capture events.
Metadata
Section titled “Metadata”Guard, Protect, and every Guard rule accept Metadata: an
arcjet.Metadata (map[string]any) whose values can be any JSON-serializable
value, including nested maps and slices.
decision, err := guard.Guard(ctx, arcjet.GuardRequest{ Label: "tools.get-weather", Metadata: arcjet.Metadata{ "user": map[string]any{"id": userID, "plan": "pro"}, "toolName": "get_weather", "duration_ms": 160, "success": true, }, Rules: []arcjet.GuardRuleInput{userLimit.Key(userID, 1)},})On Protect, pass arcjet.WithMetadata(...).
The SDK JSON-encodes each top-level value and Arcjet stores it verbatim, so an
int64 keeps its exact value. The server allows 128 top-level keys,
4 KiB per serialized value, and 10 levels of nesting, and key names can
contain letters, digits, -, ., and _. Arcjet drops a key that exceeds a
limit.
Metadata never fails a call or changes a decision. Arcjet reports dropped keys
in decision.Warnings. The SDK collects the keys that it can’t encode (a
channel, a func, a cycle, NaN, or a string holding invalid UTF-8) into a
single AJ1017 warning that names them. Go maps have no insertion order, so
the SDK processes keys in sorted order.
Arcjet treats metadata as untrusted and doesn’t redact it, so keep secrets and personal data out of it. For the full limit table, see Guard metadata.
Outbound proxy
Section titled “Outbound proxy”The SDK honors the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY
environment variables for Arcjet API calls. Proxy URLs can contain credentials,
so don’t log them.