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.
Caution: The Go SDK is pre-release and its API may change. Version 0.1.0 requires Go 1.25 or later.
Install
Section titled “Install”Use Go tooling so the project records a compatible version in go.mod:
go get github.com/arcjet/arcjet-go@latestSet ARCJET_KEY in the environment. Retrieve it 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 wins, so passing Key: os.Getenv("ARCJET_KEY") is still
correct.
This differs from the JS and Python SDKs. The JS Guard SDK does not read environment variables. Python Guard and Python Protect require an explicit key.
The Go SDK does not 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 |
Create either client once at package scope and reuse it. Do not create a new 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}Use client.WithRule(...) to derive a client with an additional route-specific
rule.
When you configure multiple Protect rules, declaration order does not control
evaluation. NewClient and WithRule sort 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 of later rules.
SensitiveInfo runs first so a denial happens before another rule can
forward the payload.
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 the application is behind a trusted reverse proxy, configure Config.Proxies
with the trusted proxy addresses or CIDRs so Arcjet receives the correct client
IP.
Timeouts
Section titled “Timeouts”Protect and ProtectDetails apply a 2 second deadline when the incoming
context has none. An email rule doubles that to 4 seconds. A prompt-injection
rule has a 1 second floor that the 2 second base already meets. A client
with both email and prompt-injection rules still uses 4 seconds (the email
doubling). A caller-supplied deadline is not shortened or replaced.
// 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. An empty string
does not override the automatically detected IP. WithIPSrc is also supported
by ProtectDetails.
Caution: The SDK trusts the value without validating it. Validate the value and ensure it comes from a trusted source. Do not pass a client-controlled header directly; doing so could allow clients to choose the IP address used for fingerprinting, rate limiting, and other security checks.
Sensitive information detection
Section titled “Sensitive information detection”Use SensitiveInfo on the HTTP client and pass the text to scan with
WithSensitiveInfoValue. Detection runs locally.
SensitiveInfoOptions.ContextWindowSize is the number of adjacent tokens
passed to Config.SensitiveInfoDetect at a time. When the value is zero or
negative, the SDK uses 1. This matches JS contextWindowSize and Python
context_window_size.
aj, err := arcjet.NewClient(arcjet.Config{ Key: os.Getenv("ARCJET_KEY"), SensitiveInfoDetect: func(_ context.Context, tokens []string) []arcjet.EntityType { // tokens is a window of ContextWindowSize return make([]arcjet.EntityType, len(tokens)) }, Rules: []arcjet.Rule{ arcjet.SensitiveInfo(arcjet.SensitiveInfoOptions{ Mode: arcjet.ModeLive, ContextWindowSize: 3, }), },})GuardSensitiveInfoRule does not expose ContextWindowSize. Guard scans
use a window of 1.
For more information about scanning request text, see Sensitive information detection.
Error handling
Section titled “Error handling”Arcjet fails open so a service issue does not block all requests.
When a transport failure prevents a decision, Protect and ProtectDetails
return an ERROR conclusion Decision together with err:
decision.IsAllowed()is true. AnERRORconclusion is treated 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.
Log err and deny only when IsDenied() is true. If you only inspect err,
log it and continue serving:
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(), an ERROR decision allows the request.
The zero Decision from a nil client or nil request is not allowed. Check
decision.IsErrored() when you need to distinguish a real allow from a
fail-open error.
Guard already returns a usable decision on transport failure. See Errors and warnings.
Guard non-HTTP operations
Section titled “Guard non-HTTP operations”Use Guard where no *http.Request exists. Configure the client and reusable
rules at package scope, then pass the inputs at the operation boundary:
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 and rate-limit buckets are slugs: lowercase letters, digits, dashes, and dots, starting and ending with a letter or digit. Use a stable, hardcoded label for each operation and an explicit rate-limit key such as a user ID, session ID, or API key.
Every Guard rule constructor requires Mode: ModeLive or ModeDryRun. An
empty Mode returns ErrInvalidMode. HTTP Protect rules default an empty
Mode to ModeDryRun. JavaScript and Python Guard rules default to LIVE.
Go cannot require a struct field, so the constructor returns an error instead
of defaulting to LIVE.
If you omit Bucket, the SDK uses default-token-bucket,
default-fixed-window, or default-sliding-window. Set Bucket explicitly
so unrelated rules do not share a counter.
Available Guard rules include rate limiting, prompt injection detection,
content moderation (GuardModerateContent), sensitive information detection,
and custom local rules.
Use Capture to record what the application did after a Guard call –
visibility data, never a security decision. See Capture.
Errors and warnings
Section titled “Errors and warnings”Guard separates enforcement decisions from processing problems:
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.
Choose an explicit fallback policy for sensitive operations. Arcjet fails open by default so a service issue does not block all work.
Content moderation
Section titled “Content moderation”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)},})
if decision.IsDenied() && decision.Reason == arcjet.ReasonModerateContent { 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 binary Detected verdict plus optional Billing in
text_units. 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. An empty action drops the event. Optional fields:
CorrelationId, DecisionId, Metadata, and OccurredAt (zero means now).
Delivery is best-effort: a bounded queue, batched sends, no retries. Call
Flush (or Close) on shutdown so the last batch is sent.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)defer cancel()guard.Flush(ctx)See Capture events.
Metadata
Section titled “Metadata”Guard, Protect, and every Guard rule accept Metadata: an
arcjet.Metadata (map[string]any) whose values may 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(...).
Each top-level value is JSON-encoded by the SDK and stored verbatim, so an
exact int64 survives. Server-enforced limits: 128 top-level keys, 4 KiB per
serialized value, 10 levels of nesting, and key names limited to letters,
digits, -, ., and _. Over a limit, that key is dropped.
Nothing here can fail a call or change a decision. Dropped keys are reported
on decision.Warnings. Keys the SDK cannot encode (a channel, a func, a
cycle, NaN, or a string holding invalid UTF-8) are collected into a single
AJ1017 warning naming them. Go maps have no insertion order, so keys are
processed in sorted order.
Metadata is untrusted and is not redacted – do not put secrets or PII in 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 may contain credentials,
so do not log them.