Skip to content

v0.21.0

Release Date: 2026-10-25

This is a security release and it is breaking. Tokens are now bound to a key their holder must prove (DPoP) instead of working for whoever holds a copy; keys and secrets rotate with the previous value kept valid; access removed in the database is removed from the authorization graph first; and each multi-app application gets a database login of its own. It also closes a long list of authentication, session, invitation, SCIM and BFF gaps. Read Upgrading before you upgrade.

Highlights

  • DPoP-bound tokens by default. SystemAuth and the identity/oauth provider verify DPoP proofs (RFC 9449) at the token endpoint and bind access and refresh tokens to the proof's key (cnf.jkt, token_type DPoP). The relying-party BearerMiddleware, the session middleware (HTTPAuth and friends) and multiapp.AuthMiddleware accept a bound token only with the DPoP scheme and a fresh, unreplayed proof by its key, and never as Bearer; the BFF presents tokens with proofs by the session's key. Unbound bearer tokens remain only as an explicit per-client opt-out (allow_bearer_tokens). SCIM tenant tokens stay Bearer by design, since identity providers do not send DPoP proofs. See DPoP Enforcement.
  • N / N-1 rotation everywhere. JWT signing keys carry key IDs, and verification keys let the next key be pre-staged before it signs and the previous one keep verifying after. Client secrets, BFF session sealing keys and SCIM tokens likewise keep the current and previous value valid and retire only the one before. Rolling back a promotion signs nobody out.
  • Built-in per-tenant SCIM tokens. The new identity/scim/scimtoken store issues, rotates, revokes and authenticates tokens bound to one organization, storing only a hash. The packaged service (systemauthsvc) serves SCIM 2.0 at /scim/v2 with them and token management under /api/v1/organizations/{slug}/scim-tokens. See SCIM Provisioning.
  • Revoke first, then reconcile. Every revocation removes the relationship from the SpiceDB graph inside its transaction, under PostgreSQL advisory locks, and fails rather than commit when the graph refuses, so any mismatch is toward less access. Grants re-read their rows under the same locks before writing. A reconciler repairs the graph from the identity tables, and the packaged service can run in mode rebac. See Authorization Mode.
  • A database login per app. In multi-app (and single-app) mode, each application connects with its own login, checked at startup to be confined to its schema, with read-only access to the shared identity tables. Migrations run as a separate step with an owner login that app servers never hold. See Multi-App.

Security

Tokens and sessions:

  • Bearer tokens worked for whoever held them, and nothing verified the DPoP proofs the BFF sent, so a stolen DPoP-bound token worked as a bearer token. identity/token also bound tokens to an unverified thumbprint.
  • Tokens of a hard-deleted principal kept working, since an unknown token subject counted as active.
  • identity/oauth API.Middleware accepted tokens of any client, and of inactive clients and principals.
  • An organization-scoped API key kept working after its owner left the organization or was deactivated.
  • The session JWT service accepted HMAC secrets of any length and checked the issuer only when configured.
  • The token endpoints parsed the form before their 1 MB body limit.

Sign-in and accounts:

  • The embedded auth provider signed in as any user for the oidc method without checking a token.
  • Password authentication had no lockout and leaked account existence by timing.
  • A managed principal's login session could authorize clients that do not allow managed principals.
  • POST /login-token accepted cross-site posts (login CSRF).
  • Signup could hand a verified login an account whose email was never proven, and changing a human's email kept it verified.

Organizations, invitations and delegation:

  • Invitations could be listed and answered with an unverified email, accepted after the inviter lost its authority, and accepted while a revoke or decline was committing.
  • A deleted organization stayed usable under RBAC.
  • Organization admins could add any principal, including another tenant's managed principals, as a member without consent.
  • Any principal with can_delegate could take over any agent.
  • The access-change feed served every organization's changes to every client.
  • The contract audit stream state was shared by every application in the process.

Authorization:

  • Revocations committed before removing graph access, so a failed graph write left access in place, and a late grant write could undo a concurrent revocation.
  • Deactivating a principal, demoting a member to collaborator, deleting a SCIM user and deactivating a human through SystemAuth storage left graph relationships behind.
  • CanAll with no actions allowed every caller.
  • The GuardSQL adapter gave every entity and field the same resource.
  • The embedded SpiceDB server listened on a local port with a guessable key.

SCIM:

  • Unsupported filters matched every user.
  • PATCH and PUT stored passwords unhashed, and PUT echoed them.
  • Group writes bypassed the organization service.
  • Database error text reached clients, and bulk requests had no payload or password-hashing bound.

BFF:

  • The proxy forwarded dot segments outside its base path, forwarded client identity headers, and skipped the origin check for safe methods.
  • Logout reported success when the store failed.
  • Sessions had no idle timeout or maximum lifetime.
  • Per-endpoint rate limits never applied.
  • The session cookie could be shared across subdomains.
  • The omnistorage store kept tokens and DPoP private keys in plaintext, dropped the site ID on update and shared its per-user index between applications; the Ent store's ciphertext was not bound to its row or column and its key could not be rotated.
  • The omnistorage store's Redis connection could cross the network without TLS or a password.
  • The session sealer, and the relying-party store's row keyring, had no bound on the data they encrypt.

Elsewhere:

  • The Go directive was 1.26.6, missing standard-library security fixes now carried by 1.26.10.
  • The observability middleware sent full URLs, with codes and tokens, to the tracing backend; productgraph trusted X-User-ID.
  • Stripe webhooks were verified with an empty key when no secret was set, and licenses ignored subscription status. A suspended license stayed valid at the instant it was suspended.
  • Marketplace seat assignment could overrun the seat limit, and the Redis rate limiter could undercount a burst.
  • Targeted feature flags were on for callers without a subject.
  • multiapp's schema hook leaked search_path across pooled connections, and rls interpolated names unquoted.
  • A root key stored under the keys directory was accepted.
  • The connections development egress setting opened every non-public address; it now lists only the ranges a local setup needs.
  • The OAuth client state cookie could be written without Secure.

The changelog describes each fix.

Upgrading

Work through the steps that apply to the packages you use. Most of the compile-time changes are listed with a before and after.

1. Go toolchain and dependencies

The go directive is now 1.26.10, so building this module, and any application that depends on it, needs Go 1.26.10 or later.

This release requires these dependency versions, each with its own upgrade notes:

Module Version
github.com/authzed/spicedb v1.57.0
github.com/grokify/guardsql v0.4.0
github.com/plexusone/connectforge v0.6.0
github.com/plexusone/keyforge v0.2.0
github.com/plexusone/omniobserve v0.12.1
github.com/plexusone/omnistorage-core v0.6.0
github.com/plexusone/omnivault v0.6.0

The steps below cover what they change for SystemForge users.

2. SystemAuth: DPoP-bound tokens

  1. Run migrations/0010_oauth_app_allow_bearer_tokens.sql against an existing database. It adds sf_oauth_apps.allow_bearer_tokens, false for every client. Fresh databases need nothing. Then run systemauth migrate (or systemauthsvc.Service.Migrate), which creates the new sf_scim_tokens table.
  2. Clients must send a DPoP proof with every token request and present their tokens with Authorization: DPoP and a proof. For a client that cannot yet, set allow_bearer_tokens: true on it; it then gets unbound bearer tokens.
  3. With several SystemAuth instances, set dpop.replay_cache_redis_url so they share one replay cache. Behind a proxy that strips a path prefix, set dpop.resource_base_url to the external base URL.
  4. EmbeddedOAuthProvider.UserInfo refuses DPoP-bound tokens.

3. SystemAuth: other changes

  • Token subjects. A custom SessionProvider must return principal IDs; tokens for any other subject are refused. Custom Storage implementations must implement PrincipalActive.
  • Client secrets. Only the two newest active secrets authenticate. Rotate with EntStorage.RotateClientSecret, which keeps the previous secret and revokes older ones.
  • Signing keys. List previous and pre-staged keys in keys.verification_keys. Rotate by pre-staging the new key, then promoting it while the previous key stays listed.
  • Access-change feed. WithAccessChanges takes a scope:
// Before
systemauth.WithAccessChanges(store)
// After: one application, or a function naming each client's organizations
systemauth.WithAccessChanges(store, systemauth.AllOrganizations)
  • Connections keyring. keyforge v0.2.0 authenticates the keyring's rollback state, so an existing connections keyring refuses to open after the upgrade. Stop SystemAuth, accept it once, then start it as usual:
systemauth keys accept-rollback --confirm --config /etc/systemauth/config.yaml

Run the same command after restoring the keys directory from a backup or moving it to a new host. Embedding hosts call systemauthsvc.AcceptKeyRollback. - Connections egress. allow_private_egress is removed. For development against local fakes, list only the ranges they use:

# Before
connections:
  allow_private_egress: true
# After
connections:
  egress_allow_prefixes: [127.0.0.0/8]
  allow_http_egress: true

Link-local and cloud metadata addresses are refused even when listed. - Deactivation. EntStorage.UpdateHuman with Active false now deactivates through the principal service and fails, leaving the human active, when the graph refuses. Give the storage the syncer with WithEntAuthzSyncer.

4. identity/oauth provider

  • NewProvider requires Config.PrivateKey; set AllowEphemeralKey only in development and tests.
  • API.Middleware panics unless the API is created with WithAcceptedClientIDs(...) or WithAnyClient().
  • Clients without allow_bearer_tokens must use DPoP as in step 2. Set WithResourceBaseURL for routes behind a proxy. Provider.Session returns *systemauth.Session.
  • identity/token: IssueInput.DPoPJKT and RefreshInput.DPoPJKT are replaced by DPoP *dpopproof.Proof, from a dpopproof.Checker.

5. Relying parties

Set Config.DPoPKey on the Client (the same key on every instance that shares sessions), and give BearerMiddleware a DPoP config; it panics when Tokens is set without one:

// Before
relyingparty.BearerMiddleware(relyingparty.BearerConfig{Tokens: tokens, Principals: principals})
// After
relyingparty.BearerMiddleware(relyingparty.BearerConfig{
    Tokens:     tokens,
    Principals: principals,
    DPoP: &relyingparty.DPoPConfig{
        ReplayCache: middleware.NewRedisReplayCache(redisClient, "dpop-jti:"),
        BaseURL:     "https://api.example.com",
    },
})

AllowBearerTokens: true instead keeps accepting unbound tokens from legacy clients.

6. Session middleware

HTTPAuth, HTTPAuthForAudience, HTTPAuthOptional, ChiAuth and ChiAuthOptional require DPoP, take a DPoPConfig and return an error:

// Before
r.Use(middleware.HTTPAuth(jwtService))
// After
auth, err := middleware.HTTPAuth(jwtService, middleware.DPoPConfig{
    ReplayCache: middleware.NewRedisReplayCache(redisClient, "dpop-jti:"),
    BaseURL:     "https://api.example.com",
})
if err != nil {
    return err
}
r.Use(auth)

To keep accepting only bearer tokens, use HTTPAuthBearer, HTTPAuthBearerForAudience or HTTPAuthBearerOptional; the ...WithObservability variants are renamed the same way.

APIKeyMiddleware needs APIKeyMiddlewareConfig.OwnerCheck, and RequireAPIKey, RequireAPIKeyWithScopes and OptionalAPIKey take an APIKeyOwnerCheck that confirms the key's owner is still active and a member of the key's organization.

7. Session JWT

jwt.Config.Issuer is required, and an HMAC secret must be at least 32, 48 or 64 bytes for HS256, HS384 or HS512. Set KeyID to put a kid on tokens and use VerificationKeys to rotate.

8. Multi-app

  • AuthMiddleware returns an error and requires DPoP:
// Before
r.Use(multiapp.AuthMiddleware(multiapp.MiddlewareConfig{JWTService: jwtService}))
// After
auth, err := multiapp.AuthMiddleware(multiapp.MiddlewareConfig{
    JWTService: jwtService,
    DPoP:       middleware.DPoPConfig{ReplayCache: replayCache, BaseURL: "https://apps.example.com"},
})
if err != nil {
    return err
}
r.Use(auth)

DPoP.AllowBearer keeps accepting unbound bearer tokens. - Database logins. Config.DatabaseURL is removed:

// Before
multiapp.Config{DatabaseURL: os.Getenv("DATABASE_URL")}
// After (production)
multiapp.Config{
    AppDatabases:  map[string]multiapp.AppDatabase{"billing": {URL: os.Getenv("BILLING_DATABASE_URL")}},
    MigrationRole: "systemforge_owner",
}

Provision each app login with multiapp.RoleProvisioning or the new multiappdb command (multiappdb sql, apply, verify), and run multiapp.Migrate with the owner login as a deploy step before the app servers start. RegisterApp and Run fail when a login is not confined to its schema, and RegisterApp fails when the app schema does not exist. For local development, set AllowSharedDatabaseLogin with MigrationDatabaseURL, or AllowInProcessMigrations to keep migrating in process. - Shared tables. App logins get SELECT on the shared identity tables (DefaultSharedReadTables) by default. To provision a login without them, set SharedReadTables to multiapp.NoSharedReadTables or pass --no-shared-tables, then apply the provisioning again; earlier grants are not revoked by re-applying. - Shared pools. An AppAwareDB serves one schema unless opened with AllowSharedLogin or AsMigrationLogin, and NewEntConnHook needs the AppAwareDB of the login its transactions use. Prefer EntConnHook.SetSchemaInTx to SetSchemaFromContext.

9. BFF

  • Cookie. The default session cookie is __Host-sf_session (sf_session with AllowInsecure), and CookieConfig.Domain is removed; drop it. Applications that shared one session across subdomains give each host its own session. Browsers holding the old cookie sign in again.
  • Omnistorage store. The redis backend needs a SiteID and a sealer:
sealer, err := bff.NewKeyringSealer("k1", map[string][]byte{"k1": key})
if err != nil {
    return err
}
store, err := bff.NewOmniStorageStore(bff.OmniStorageConfig{
    Backend: "redis",
    SiteID:  "billing",
    Sealer:  sealer,
    // ...
})

NewKeysSealer seals with a keyforge purpose key instead. NewOmniStorageStoreWithKVS now returns an error and needs the same. The default key prefix is session:<SiteID>:; set KeyPrefix explicitly to keep the old layout. Sessions written without a sealer or under the old prefix are not found, and their users sign in again. - Redis. A RedisURL naming a server that is not on loopback must use TLS with certificate verification and a password (rediss://:password@redis.internal:6379). Set AllowInsecureRedis only for a development Redis on a local network; it logs a warning. - Ent store. NewEntStore needs a Sealer, or keeps working with EncryptionKey under the key ID default. Rows from earlier versions still open and are resealed on their next update; rolling back to an earlier release signs out users whose sessions were written since. - Session lifetime. Sessions end after IdleTimeout (24 hours) without use and MaxLifetime (7 days) after creation. Set a negative value to disable either. - Proxy. HeadersToForward is now enforced as an allow-list; add any header an upstream needs. Client identity headers are always stripped; set them from the session in OnRequestRewrite.

The state cookie is __Host-sf_oauth_client_state, so flows in progress during the upgrade fail once. SetStateCookie returns an error, NewStateManagerInsecure takes a *slog.Logger, a StateManager is Secure unless AllowInsecure is set, and SameSite Strict or None is refused. See Browser Cookies.

11. Authorization graph and identity services

  • Revocations. Removing a member, a role, an application role, a resource grant, a manager or platform administration now fails, and changes nothing, when the graph refuses the removal, also in SyncModeEventual. Handle the error and retry. approle.RemoveForMember removes nothing on a graph error, and resourcegrant.GrantInTx may write to the graph before the caller commits when it lowers a grant.
  • Deactivation. principal.Deactivate fails, and the principal stays active, when the graph refuses; Update with Active false records a deactivation access change.
  • Collaborators. Demoting a member to collaborator deletes their application roles in the organization; assign them again after a promotion.
  • Reconciler. In mode rebac the reconciler removes the relationships of deactivated principals and collaborators' application-role relationships. systemauthsvc runs it from RunJobs; switching from rbac to rebac fills the graph through its startup run.
  • Direct member adds. Admins can add members through POST /organizations/{slug}/members only when APIConfig.DirectAdd allows it; allow the principals your application governs there.

12. SCIM

  • Set scim/store Config.Syncer to the deployment's syncer so deleting a user removes its graph relationships. Deleting a user who actively manages other accounts answers 409; transfer their management first.
  • A platform-scoped CreateGroup refuses an externalId that is not a valid, available organization slug, and DeleteGroup soft-deletes the organization and refuses personal organizations.
  • With systemauthsvc, set Options.SCIM to serve SCIM with stored tenant tokens. Access tokens granted systemforge:scim_tokens manage them.

13. Contract

RecordAuditEvent, GetAuditStreamConfig and IsAuditStreamEnabled are methods of *contract.API (RecordAuditEvent, AuditStreamConfig, IsAuditStreamEnabled).

14. Behavior to check

These need no code change but can surface after the upgrade:

  • RequireAllActions and RequireAnyAction panic when registered without actions.
  • Password sign-in locks an identifier after repeated failures; the default lockout is in memory, and WithLockout shares it, for example through Redis.
  • Stripe webhooks are refused when no signing secret is set.
  • Invitations need a verified email to list or answer.
  • A feature flag with targets or a partial rollout is off for callers that pass no subject.
  • A failed graph write after a committed change is logged as "failed to sync granted access to authz" with the kind of change in the change attribute; update log searches or alerts that matched the old per-change messages.

Known limits

  • SCIM tenant tokens are bearer secrets, since identity providers do not send DPoP proofs; keep SCIM behind TLS and a rate limiter.
  • Database logins separate applications' data, but do not contain remote code execution in multi-app mode; run applications holding sensitive data in single-app mode.
  • With a database, a client listed under clients in the SystemAuth configuration takes its secret from the file only when it is first created; editing secret afterwards has no effect. Rotate it with EntStorage.RotateClientSecret (see Rotating Keys and Secrets).