Octopus
1.x
Docs/Octopus/Secure an API with JWT
Open

Reading5 min
Updated31 Jul 2026
Sourcev1/guides/secure-api-jwt.mdx

Secure an API with JWT

This guide takes an unauthenticated upstream and puts it behind JWT bearer-token authentication: every request must present a valid token, public paths stay open, and individual routes are gated by the caller's roles and scopes. The jwt provider validates tokens against a static key, which is the right choice when you control the signing secret (HMAC) or hold the issuer's public key (RSA/ECDSA). If your identity provider rotates keys via JWKS, use the oidc provider instead — the route wiring below is identical.

1

Start from a working upstream01

Begin with a gateway that proxies a backend but enforces no auth. Everything in this file is required by the schema (gateway.listen and each upstream instance's id/host/port).

gateway:
  listen: "0.0.0.0:8080"

upstreams:
  - name: orders-api
    instances:
      - id: orders-1
        host: 10.0.0.21
        port: 8080

routes:
  - path: /orders/*
    upstream: orders-api

Declare a JWT provider02

Add an entry under auth_providers. The provider needs exactly one key source. Pick the form that matches how your tokens are signed.

yaml
auth_providers:
  primary-jwt:
    type: jwt
    secret: "${JWT_SECRET}"      # HS256/384/512 shared secret
    algorithm: HS256
    issuer: https://auth.example.com
    audience: orders-api

When set, issuer and audience are checked against the token's iss/aud claims; expiry (exp) is always checked. The full field list is on the JWT provider page.

Note

${JWT_SECRET} is resolved from the environment at load time. octopus validate and octopus serve both fail fast if the variable is unset, so export it (or use a ${VAR:-default} form) before running.

Enforce authentication globally03

Set the provider as the default and turn on global enforcement so every route requires a valid token unless it opts out. Use skip_paths for unauthenticated endpoints (exact match, or a trailing * prefix).

auth:
  default_provider: primary-jwt
  global_enforce: true
  skip_paths:
    - /health
    - /public/*
  token_cache_ttl: 60s

With global_enforce: true, a request with no credentials on an enforced route gets 401 unauthorized with a WWW-Authenticate: Bearer challenge; a request with invalid credentials always gets 401 regardless of enforcement. Successful authentications are cached for token_cache_ttl (keyed by the bearer token), so keep it short relative to your token lifetimes. See the authentication flow for the full decision table and the token cache details.

Gate routes by role and scope04

Authentication proves who the caller is; authorization decides what they may do. Roles come from the token's roles claim and scopes from the whitespace-split scope claim. On a route:

  • require_roles — the principal must have at least one (any-of).

  • require_scopes — the principal must have all (all-of).

  • authz_rule — a Rhai expression for finer control (true allows, false denies).

routes:
  - path: /health
    upstream: orders-api
    skip_auth: true                         # public, no token required

  - path: /orders/*
    upstream: orders-api
    require_scopes: ["orders:read"]         # token must carry this scope

  - path: /admin/*
    upstream: orders-api
    require_roles: ["admin"]                # any-of these roles
    authz_rule: 'has_role("admin") && request.method != "DELETE"'
Warning

A standard JWT without a roles or scope claim authenticates with empty roles and scopes, so it will fail any non-empty require_roles / require_scopes with 403 forbidden. Make sure your issuer emits the claims you gate on. See Authorization.

Validate and run05

Assemble the file and validate it before serving:

export JWT_SECRET=change-me
octopus validate -c gateway.yaml
octopus serve -c gateway.yaml

The complete, validated configuration:

gateway:
  listen: "0.0.0.0:8080"

upstreams:
  - name: orders-api
    instances:
      - id: orders-1
        host: 10.0.0.21
        port: 8080

auth_providers:
  primary-jwt:
    type: jwt
    secret: "${JWT_SECRET}"
    algorithm: HS256
    issuer: https://auth.example.com
    audience: orders-api

auth:
  default_provider: primary-jwt
  global_enforce: true
  skip_paths:
    - /health
    - /public/*
  token_cache_ttl: 60s

routes:
  - path: /health
    upstream: orders-api
    skip_auth: true
  - path: /orders/*
    upstream: orders-api
    require_scopes: ["orders:read"]
  - path: /admin/*
    upstream: orders-api
    require_roles: ["admin"]
    authz_rule: 'has_role("admin") && request.method != "DELETE"'

Try it06

# No token on an enforced route -> 401
curl -i http://localhost:8080/orders/123

# Public path is open
curl -i http://localhost:8080/health

# With a valid bearer token carrying the orders:read scope
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/orders/123

The gateway injects the principal id, roles, and scopes onto the upstream request as X-Auth-Principal, X-Auth-Roles, and X-Auth-Scopes so your backend can trust them.

See also07