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/oauthprovider verify DPoP proofs (RFC 9449) at the token endpoint and bind access and refresh tokens to the proof's key (cnf.jkt,token_typeDPoP). The relying-partyBearerMiddleware, the session middleware (HTTPAuthand friends) andmultiapp.AuthMiddlewareaccept 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/scimtokenstore issues, rotates, revokes and authenticates tokens bound to one organization, storing only a hash. The packaged service (systemauthsvc) serves SCIM 2.0 at/scim/v2with 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/tokenalso bound tokens to an unverified thumbprint. - Tokens of a hard-deleted principal kept working, since an unknown token subject counted as active.
identity/oauthAPI.Middlewareaccepted 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
oidcmethod 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-tokenaccepted 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_delegatecould 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.
CanAllwith 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_pathacross 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¶
- Run
migrations/0010_oauth_app_allow_bearer_tokens.sqlagainst an existing database. It addssf_oauth_apps.allow_bearer_tokens, false for every client. Fresh databases need nothing. Then runsystemauth migrate(orsystemauthsvc.Service.Migrate), which creates the newsf_scim_tokenstable. - Clients must send a DPoP proof with every token request and present their
tokens with
Authorization: DPoPand a proof. For a client that cannot yet, setallow_bearer_tokens: trueon it; it then gets unbound bearer tokens. - With several SystemAuth instances, set
dpop.replay_cache_redis_urlso they share one replay cache. Behind a proxy that strips a path prefix, setdpop.resource_base_urlto the external base URL. EmbeddedOAuthProvider.UserInforefuses DPoP-bound tokens.
3. SystemAuth: other changes¶
- Token subjects. A custom
SessionProvidermust return principal IDs; tokens for any other subject are refused. CustomStorageimplementations must implementPrincipalActive. - 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.
WithAccessChangestakes 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:
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¶
NewProviderrequiresConfig.PrivateKey; setAllowEphemeralKeyonly in development and tests.API.Middlewarepanics unless the API is created withWithAcceptedClientIDs(...)orWithAnyClient().- Clients without
allow_bearer_tokensmust use DPoP as in step 2. SetWithResourceBaseURLfor routes behind a proxy.Provider.Sessionreturns*systemauth.Session. identity/token:IssueInput.DPoPJKTandRefreshInput.DPoPJKTare replaced byDPoP *dpopproof.Proof, from adpopproof.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_sessionwithAllowInsecure), andCookieConfig.Domainis 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
SiteIDand 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.
10. OAuth client state cookie¶
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.RemoveForMemberremoves nothing on a graph error, andresourcegrant.GrantInTxmay write to the graph before the caller commits when it lowers a grant. - Deactivation.
principal.Deactivatefails, and the principal stays active, when the graph refuses;UpdatewithActivefalse 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.
systemauthsvcruns it fromRunJobs; switching from rbac to rebac fills the graph through its startup run. - Direct member adds. Admins can add members through
POST /organizations/{slug}/membersonly whenAPIConfig.DirectAddallows it; allow the principals your application governs there.
12. SCIM¶
- Set
scim/storeConfig.Syncerto 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
CreateGrouprefuses anexternalIdthat is not a valid, available organization slug, andDeleteGroupsoft-deletes the organization and refuses personal organizations. - With
systemauthsvc, setOptions.SCIMto serve SCIM with stored tenant tokens. Access tokens grantedsystemforge:scim_tokensmanage 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:
RequireAllActionsandRequireAnyActionpanic when registered without actions.- Password sign-in locks an identifier after repeated failures; the default
lockout is in memory, and
WithLockoutshares 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
changeattribute; 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
clientsin the SystemAuth configuration takes its secret from the file only when it is first created; editingsecretafterwards has no effect. Rotate it withEntStorage.RotateClientSecret(see Rotating Keys and Secrets).