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.

The Go SDK requires Go 1.25 or later.

Add the SDK with go get, which records a compatible version in go.mod:

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

Then set ARCJET_KEY in your environment. You can get a key from the Arcjet Console, the Arcjet CLI, or the MCP server.

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.

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
arcjet.GuardActionThe same operations, when you want the helper to run the function only if policy allows it
github.com/arcjet/arcjet-go/agentframeworkMicrosoft 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.

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.

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)

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.

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:

Terminal window
go get github.com/arcjet/arcjet-go/sensitiveinfo/rampart

Create 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.

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.

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 an ERROR conclusion 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 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 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.GuardDeniedError
var unavailable *arcjet.GuardUnavailableError
switch {
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.

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.

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

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.

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.

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.

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.