Skip to content

v0.28.0

Release Date: 2026-10-29

A security release that slows abuse, makes incidents traceable and writes the risks down:

  • Rate limits in layers, on by default. SystemAuth limits every route per client address, applies tighter route policies to the token, login, sign-up and BFF sign-in paths, limits failed client authentication, the JWT-bearer grant and social login, and limits signed-in callers, all on one shared storage and answered with RateLimit headers.
  • One client-address rule. session/clientaddr believes a forwarding header only from trusted proxy networks. SystemAuth, the BFF, appauth, rate limits and security events all use it.
  • Logs and events fit for incidents. Structured security events through contract/audit, OAuth errors logged without stack traces, tests proving no token or secret reaches a log or event, and no internal error text in API responses.
  • Static API keys are removed from relying parties.

It also adds a threat model and closes the smaller findings of the launch review: the BFF session cookie refuses SameSite=None, EmbeddedOAuthProvider.Revoke revokes, and every server systemforge builds sets ReadHeaderTimeout.

The release is breaking. Applications that passed APIKeys stop compiling, rate limits are on without any configuration, and some organization API status codes and error messages change. A SystemAuth behind a reverse proxy must set client_address, or every visitor shares the proxy's address and its limits. Read Upgrading first.

Highlights

  • Layered rate limits: a default of 300 requests a minute per client address, route policies for token, login, signup and bff_auth, handler limits on credential guessing, and a signed-in limit of 1200 a minute per principal or service account.
  • session/clientaddr: trusted proxy networks and the header they write, configured once as client_address and shared by every part of the server.
  • Security events for sign-ins, refusals, sign-outs, token replay, revocations, membership and role changes, service accounts and production waivers, each with the request ID and the visitor's address.
  • No internal error text in about 60 API responses.
  • A threat model with the trust boundaries, the threats to each and the package that mitigates it.

Security

  • Rate limits. SystemAuth had no limit on most routes, so credential endpoints could be guessed at and any route flooded. It now applies, by default and outermost:
  • a limit per client address on every route (300 a minute);
  • route policies: /oauth/token per address (120 a minute) and per OAuth client (1200 a minute), /login* (120), /signup* (60) and /bff/auth/* (120) per address;
  • exemptions for health checks, static assets and the billing webhook, whose provider calls from shared addresses.

Inside handlers it limits failed client authentication at the token endpoint, per address and client and then per address; tokens the JWT-bearer grant issues per service account; and social login start and callback per address. The login-token, waitlist, sign-up code and managed-principal code limits move onto the same storage, and the managed-principal API applies the signed-in limit after authentication. - Rate limit responses. A refused request gets 429 with Retry-After in whole seconds, never below 1. Every checked response carries the IETF RateLimit-Policy and RateLimit headers beside X-RateLimit-*. - Storage outages. When the storage fails, the default layer and the signed-in limit let the request through and log the failure; the route policies and the credential endpoints' handler limits refuse with 503. A full in-memory storage is always a 429, so a flood of new addresses cannot switch the limits off. - Client addresses. Client addresses were found in several ways, some of which believed forwarding headers from any peer. session/clientaddr believes X-Forwarded-For, read from the right, or a single-value header such as CF-Connecting-IP, only when the direct peer is a trusted proxy, and otherwise uses the peer. - OAuth error logs. Token, authorize and access-token log records carried the error value, which slog's text handler formats with its stack trace. They now carry the RFC 6749 error code, description, hint and status; the cause is added, by message only, at debug level or for server errors. - No secrets in logs or events. Tests run social sign-in, the authorization code flow with PKCE, refresh, code and refresh-token replay, client credentials, JWT bearer, revocation, logout and BFF sign-in and sign-out with logging at debug level, and fail if any token, code, verifier, client or HMAC secret, DPoP proof, assertion, key or session cookie appears in a log record or security event. - Security events. SystemAuth, organizations, platform administration and service accounts record structured events through an audit.Emitter, each with the request ID and the visitor's address taken from the client-address rule. See Security events. - Internal error text in responses. About 60 responses across the organization, marketplace, contract, invite and connections APIs, session/middleware, managed principals, SystemAuth identity sync and the sign-up API copied the underlying error, such as a database error, into the body. Known errors now map to fixed messages; anything else is logged and answered with a generic 500. - BFF session cookie. bff.CookieConfig accepted SameSite=None, which lets the browser send the session cookie on cross-site subrequests; the origin check that protects state-changing requests relies on the browser withholding it. Validate, and so NewHandler, now refuse it. - EmbeddedOAuthProvider.Revoke passed the raw token where storage expects a request ID, so it reported success without revoking anything. It now revokes the access and refresh tokens of the token's family. - ReadHeaderTimeout. The multiapp server and the publicclient loopback callback server set none, so a client could hold a connection by sending headers slowly. Every server systemforge builds now sets it. - Static API keys were a second, weaker way into a relying party: long-lived bearer secrets outside the short-lived, revocable access tokens every application already verifies. They are removed.

Breaking Changes

  • API keys removed from relying parties: appauth.Options.APIKeys, relyingparty.BearerConfig.APIKeys, relyingparty.APIKeyValidator, relyingparty.AuthMethodAPIKey and relyingparty.AuthenticatedPrincipal.APIKeyID. A Bearer value that is not a JWT is answered 401 with invalid_token, and X-API-Key is ignored. identity/apikey and the session/middleware API key middleware are unchanged.
  • Rate limits are on by default. Set rate_limit.disabled: true to turn off the default layer and the route policies when the host applies its own ratelimit.Policy in front of SystemAuth; the handler limits stay on.
  • Organization API status codes:
  • the personal-organization conflict is 409 on every path, including create and remove;
  • a storage error while reading a member is 500 instead of 404;
  • an unexpected error on create or remove is 500 instead of 400.
  • Fixed error messages. The APIs listed under Security answer fixed messages instead of the underlying error text, and the bearer and DPoP middlewares answer the matching sentinel's text or authentication failed.
  • contract.ToContractError no longer copies a non-contract error's text into its message.
  • ratelimit.Limiter.Middleware answers 429 instead of 503 when a MemoryStorage is full.
  • bff.CookieConfig.Validate rejects SameSite=None.
  • EmbeddedOAuthProvider.Revoke returns an error wrapping ErrInvalidToken for an unknown, expired or already revoked token.

Added

  • session/clientaddr: Resolver, Config (TrustedProxies, Header), New, MustNew, LoopbackProxy, Peer, PeerAddr, PeerIP and ParseNetworks.
  • Client address settings: client_address.trusted_proxies and client_address.header in SystemAuth's configuration, systemauth.WithClientAddressResolver, scimtoken.WithClientAddress, bff.ClientIPConfig.Resolver, appauth.Options.ClientAddress and Authenticator.ClientAddress.
  • session/ratelimit:
  • Policy, PolicyConfig and NewPolicy, with Route, RouteLimit, DefaultRoutes, MergeRoutes, the route names (RouteToken, RouteLogin, RouteSignup, RouteBFFAuth, RouteHealth, RouteStatic, RoutePaymentWebhook), KeyAddress, KeyClient and AddressKey;
  • NewSignedInLimiter and SignedInKey;
  • Check, Exhausted and Record for handler limits;
  • WriteHeaders, WriteTooManyRequests and RetryAfterSeconds;
  • WithName, WithSkip, and Peek on MemoryStorage and RedisStorage.
  • Rate limit settings: rate_limit.disabled, redis_url, single_instance, default, signed_in, exempt_networks and routes (name, paths, methods, per_address, per_client, exempt, fail_open) in SystemAuth's configuration; systemauth.WithRateLimitStorage, Server.RateLimitStorage, Server.RateLimitPolicy, Server.SignedInRateLimit and Server.ClientAddress; systemauthsvc.Options.RateLimitStorage; appauth.Options.SignedInRateLimit.
  • contract/audit: Context.RequestID, RequestID, NopEmitter and NewLogEmitter; requestprincipal.AuditActor.
  • Security event sources: systemauth.WithAuditEmitter, organization.WithAuditEmitter, principal.WithAuditEmitter and serviceaccount.Config.Audit. systemauthsvc.Options.AuditEmitter reaches SystemAuth and the services the package builds, and each production check waived at startup is recorded as a systemauth.production.waiver event. The systemauth command writes its security events to its log.
  • Sentinel errors:
  • organization.ErrOwnerRequired, ErrAlreadyMember, ErrNewOwnerNotMember and ErrInvalidOwnerRole;
  • marketplace.ErrInvalidListing and ErrListingNotDeletable;
  • invite.ErrInviteNotFound, ErrPendingInviteExists, ErrAlreadyMember, ErrAppRolesNotConfigured and ErrCollaboratorAppRoles.

Deprecated

Each still works and is built on session/clientaddr:

  • ratelimit.ForwardedClientIP and ratelimit.ForwardedIPKey: use a clientaddr.Resolver with ratelimit.AddressKey.
  • systemauth.WithLoginTokenClientIP: configure client_address, or use WithClientAddressResolver.
  • bff.CloudflareClientIPConfig and the ClientIPConfig fields TrustCloudflare, TrustProxy, TrustedProxies and CloudflareIPRanges: set ClientIPConfig.Resolver.

Upgrading

New SystemAuth settings. Values reach the YAML through ${VAR} references, so the variable name is a convention.

YAML key Suggested variable Needed when
client_address.trusted_proxies — SystemAuth is behind a reverse proxy
client_address.header — The proxy writes a header other than X-Forwarded-For
rate_limit.redis_url SYSTEMAUTH_RATE_LIMIT_REDIS_URL Several instances serve SystemAuth
rate_limit.single_instance: true — One instance serves SystemAuth
rate_limit.exempt_networks — Back-channel callers share one address
rate_limit.routes — A route policy needs other limits
  1. Set the client address behind a proxy. Without it every visitor has the proxy's address, so the per-address limits apply to all of them together. For a reverse proxy on the same host that replaces X-Forwarded-For:

    client_address:
      trusted_proxies: ["127.0.0.1/32", "::1/128"]
      header: X-Forwarded-For
    

    See Client addresses and Deploying SystemAuth. 2. Share the counters. With several SystemAuth instances, set rate_limit.redis_url; with one, set rate_limit.single_instance: true to silence the startup warning. See Rate limits. 3. Exempt or raise limits for back-channel callers. A relying party that calls /oauth/token from one server address shares the token route's per-address limit across all its people. Add its network to rate_limit.exempt_networks, or raise the token route's limits:

    rate_limit:
      exempt_networks: ["10.20.0.0/16"]
      routes:
        - name: token
          paths: ["/oauth/token"]
          per_address: {requests: 600, period: 1m}
          per_client: {requests: 6000, period: 1m}
    
  2. Move off static API keys. Remove APIKeys from appauth.Options and relyingparty.BearerConfig, move command-line tools to identity/publicclient sign-in and automation to service accounts, then revoke the keys. See Upgrading: static API keys removed.

  3. Record security events. Set systemauthsvc.Options.AuditEmitter, or systemauth.WithAuditEmitter, organization.WithAuditEmitter, principal.WithAuditEmitter and serviceaccount.Config.Audit, for example with audit.NewLogEmitter(logger), and ship the events off the host. Without an emitter they are discarded. See Recording events.
  4. Expect 429 responses. Clients should honour Retry-After and may read the RateLimit headers.
  5. Check API clients that relied on the old organization status codes, or on error text in responses, which is now a fixed message.
  6. Use SameSite Strict or Lax for the BFF session cookie, and handle the error EmbeddedOAuthProvider.Revoke now returns for a token it cannot revoke.

Walk the production checklist before going live.

Documentation

For release history, see the changelog.