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. The current pre-release is `v1.0.0-rc.2`, which requires Go 1.25 or later.

Client IP address detection
---------------------------

[Section titled “Client IP address detection”](#client-ip-address-detection)

Configure every trusted ingress

Outside supported hosting platforms, Arcjet first uses a public address from `Request.RemoteAddr`. When the direct peer matches `Config.Proxies`, Arcjet walks `X-Forwarded-For` from right to left and skips configured proxy hops. If `RemoteAddr` is missing or non-public, Arcjet may use a public address from a common forwarding header so protection can still run. That fallback has `Provenance == "unverified-header"` and `Verified == false`. The selection is logged at debug level with the `client_ip_provenance` facet, and the fallback produces one warning for the lifetime of each Arcjet client instance.

In production, make the application reachable only through the configured proxies and ensure they overwrite or safely append `X-Forwarded-For`. Configure the actual direct proxy and every trusted hop in `Config.Proxies`. On a supported hosting platform, use `Config.Platform` when automatic platform detection is unavailable. If your application determines the client IP itself, pass a validated value with `WithIPSrc`. Malformed proxy entries and manual IPs are rejected; `0.0.0.0/0` and `::/0` produce a warning because they trust an entire address family. The exact addresses `0.0.0.0` and `::` do not. Use `client.ClientIPDetails(request)` to inspect the selected address and its provenance without protecting the request.

Install
-------

[Section titled “Install”](#install)

Use Go tooling so the project records a compatible version in `go.mod`. Name the pre-release explicitly, because `@latest` selects the newest stable tag and this API is not in one yet:

Terminal window

```bash
go get github.com/arcjet/arcjet-go@v1.0.0-rc.2
```

Set `ARCJET_KEY` in the environment. Retrieve it from the [Arcjet Console](https://console.arcjet.com), the [Arcjet CLI](/cli), or the [MCP server](/mcp-server).

Environment variables
---------------------

[Section titled “Environment variables”](#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`](/environment#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](/environment).

Choose an API
-------------

[Section titled “Choose an API”](#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”](#protect-http-handlers)

Configure shared rules on an `arcjet.Client`, then call `Protect` once inside each handler:

```go
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](#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”](#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.

```go
// 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”](#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`:

```go
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 is rejected, as is any malformed IPv4 or IPv6 address. `WithIPSrc` is also supported by `ProtectDetails`.

> **Caution:** Syntax validation does not establish provenance. Ensure the value 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.

Inspect automatic detection without sending a decision:

```go
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 may also report `manual` when `WithIPSrc` is used or `request` when `ProtectDetails.IP` is supplied. Debug logs expose the provenance as `client_ip_provenance` alongside `client_ip_verified` and `client_ip_header`. `Verified` means the SDK tied the source to the request path; it does not certify your deployment.

### Sensitive information detection

[Section titled “Sensitive information detection”](#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`.

```go
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](/sensitive-info).

Error handling
--------------

[Section titled “Error handling”](#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. 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:

```go
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](#errors-and-warnings).

Guard non-HTTP operations
-------------------------

[Section titled “Guard non-HTTP operations”](#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:

```go
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](#capture).

### Errors and warnings

[Section titled “Errors and warnings”](#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.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.

### Content moderation

[Section titled “Content moderation”](#content-moderation)

```go
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](/content-moderation).

### Capture

[Section titled “Capture”](#capture)

Use `Capture` to record a fact about what your application did. Captures never affect `Guard` or `Protect` conclusions and never set `HasFailedOpen()`.

```go
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.

```go
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
guard.Flush(ctx)
```

See [Capture events](/guards/capture).

### Metadata

[Section titled “Metadata”](#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.

```go
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](/guards/reference#metadata).

Outbound proxy
--------------

[Section titled “Outbound proxy”](#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.

More resources
--------------

[Section titled “More resources”](#more-resources)

*   [Arcjet Guards](/guards)
*   [Capture events](/guards/capture)
*   [Content moderation](/content-moderation)
*   [Rate limiting](/rate-limiting)
*   [Prompt injection detection](/prompt-injection)
*   [Sensitive information detection](/sensitive-info)
*   [Go SDK source and examples](https://github.com/arcjet/arcjet-go)