Skip to content

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.

Use Go tooling so the project records a compatible version in go.mod:

Terminal window
go get github.com/arcjet/arcjet-go@latest

Set ARCJET_KEY in the environment. Retrieve it from the Arcjet Console, the Arcjet CLI, or the MCP server.

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.

APIUse it for
arcjet.NewClient and ProtectHTTP handlers with a *http.Request
arcjet.NewGuardClient and GuardTool 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.

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.

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)

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.

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.

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. An ERROR conclusion 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.

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.

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.Warnings contains 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.

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.

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.

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.

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.