Threat Model
This page describes what SystemForge protects, who it protects it from,
where the trust boundaries are, and which controls in the code address each
threat. It is written for operators deciding how to deploy SystemForge, for
application developers embedding it, and for security reviewers.
Read it together with the production checklist,
which turns the operator-side assumptions below into settings to verify, and
the security policy,
which explains how to report a vulnerability.
Scope and assumptions
In scope is the SystemForge module: SystemAuth (identity/systemauth,
identity/systemauthsvc, cmd/systemauth), the relying-party stack
(identity/relyingparty, identity/appauth, session/bff), OAuth and
token handling (identity/oauth, identity/token, identity/dpopproof),
organizations, invitations, SCIM and managed principals, the authorization
layer (authz, authzguardsql), connections and their credential vault
(connections, keys), billing (billing), and the database helpers
(rls, multiapp).
Out of scope are the applications built on SystemForge (their own
handlers, data model and business logic), the upstream libraries
SystemForge depends on (reported to their maintainers), and the
infrastructure an operator runs it on.
The model assumes that:
- TLS terminates at a CDN or edge proxy, the issuer and every
application are served over https, and the hop from the edge to the
origin is TLS that the edge verifies.
- The origin accepts traffic only from the edge. Clients cannot reach
the application or SystemAuth directly and bypass the edge's limits.
- Secrets come from a secret store: the signing key, the token HMAC
secret, BFF sealing keys, DPoP key pairs, the vault root key, database
passwords and payment-provider keys are not committed, not passed on
command lines and not logged.
- The operator walks the production checklist: no dev mode, shared
stores when more than one instance runs, separate database logins for the
application and for migrations, and row-level security enabled.
- The host and its operating system are trusted. Someone with root on
the host, or with access to its memory, can read every key it holds. This
model does not try to defend against that.
- Dependencies are kept current.
govulncheck runs in CI, and the Go
toolchain version carries the standard library's security fixes.
Assets
| Asset |
Why it matters |
Where it lives |
| Token signing keys |
Sign every ID token and access token. Whoever holds one can mint a token for any principal, client and audience. |
SystemAuth's key ring (identity/systemauth), loaded from the secret store |
| Token HMAC secret |
Protects the opaque refresh tokens and authorization codes. Without it, a code or refresh token cannot be verified across a restart or another instance. |
tokens.hmac_secret in SystemAuth's configuration |
| DPoP key pairs |
Bind access tokens to a key the client holds (RFC 9449). A relying party's key, together with a session's refresh token, lets its holder act as that session. |
Relying-party configuration and BFF sessions (session/bff, identity/relyingparty) |
| BFF session sealing keys |
Encrypt the tokens and DPoP private keys kept in server-side sessions. |
The BFF key ring (session/bff) |
| Connection-credential vault and its root key |
The vault holds the credentials an organization connects to third-party services; the root key decrypts it. |
connections, keys |
| Refresh tokens and sessions |
Long-lived access: login sessions at SystemAuth, refresh token families, and BFF sessions at each application. |
SystemAuth storage, BFF session stores |
| Principal and organization data |
People, service accounts, managed principals and agents; organizations, memberships, roles and invitations. |
Postgres, through Ent (identity/*) |
| The authorization graph |
Relationships that grant access when the authorizer runs in ReBAC mode. A stale relationship is a grant nobody intended. |
SpiceDB (authz/spicedb) |
| Billing state and payment-provider credentials |
Subscriptions, licenses and seats decide what an organization may use; the provider's secret key and webhook signing secrets let their holder create charges or forge events. |
billing, marketplace, Postgres, the secret store |
| Audit records |
The evidence an incident review needs: who changed whose access, and when. |
contract/audit, the audit emitter given to SystemAuth |
Actors
| Actor |
Capabilities |
Trusted for |
| Anonymous internet user |
Sends any request to any public endpoint, at any rate the edge allows. |
Nothing |
| Signed-in user |
Holds a SystemAuth login session and, at each application, a BFF session or DPoP-bound tokens. |
Their own account and the access their memberships grant |
| Organization admin |
Manages one organization's members, invitations, roles, SCIM tokens and connections. |
Their organization only, never another tenant |
| Managed principal |
An account an organization creates and controls; it signs in only to clients that allow managed principals. |
Its organization's resources, as its roles allow |
| Service account |
Automation that holds a private key and obtains DPoP-bound tokens through the JWT-bearer grant. |
Its one organization, as its roles allow |
| Relying-party application |
An OAuth client that federates to SystemAuth and verifies its tokens. |
Its own client and its own audience, not other applications' |
| SCIM provider |
An organization's identity provider, holding a tenant SCIM token. |
Users it provisioned and memberships in its organization |
| Agent or AAuth issuer |
An agent signs requests; a Person Server or agent provider issues auth tokens for it. |
What the person it acts for delegated, at a resource that trusts the issuer |
| Payment provider |
Sends signed webhooks and answers API calls. |
Payment facts it signs; never redirect targets or authorization decisions |
| Operator |
Deploys, configures and holds the secrets. |
Everything; the operator is the trust root |
| Malicious code in the same process |
A compromised dependency, or an embedded application with a vulnerability, in a host that also runs SystemAuth. |
Nothing, but it shares memory with the keys (see Residual risks) |
Trust boundaries
internet
│
┌─────────▼─────────┐
│ CDN / edge proxy │ TLS, WAF, edge rate limits
└─────────┬─────────┘
│ verified TLS, origin locked to the edge
┌─────────▼─────────┐
│ reverse proxy on │ sets the client-address header
│ the host │
└──┬─────────────┬──┘
│ │
┌──────▼──────┐ ┌────▼──────────────┐ ┌───────────────────┐
│ SystemAuth │ │ application │◄─────►│ relying-party │
│ (embedded │ │ (BFF, appauth, │ OIDC, │ apps elsewhere │
│ or alone) │ │ app code) │ JWKS └───────────────────┘
└──┬───┬───┬──┘ └──┬──────┬─────┬──┘
│ │ │ │ │ │
│ │ └──► social identity providers, AAuth issuers
│ │ │ │ │
┌──▼───▼───────────▼┐ ┌───▼──┐ ┌▼────────┐ ┌──────────────────┐
│ Postgres with RLS │ │Redis │ │ SpiceDB │ │ payment provider │
└───────────────────┘ └──────┘ └─────────┘ │ and webhooks │
└──────────────────┘
- CDN or edge proxy. The only path from the internet. Everything that
reaches the origin passed it, and the client-address header it sets is
believed only from its address ranges.
- Reverse proxy on the host. Routes by host name to SystemAuth and the
applications, and passes on, or sets, the client-address header.
- SystemAuth embedded in the application process. When a host binary
embeds SystemAuth (
identity/systemauthsvc), the signing key and the
applications' code share one address space. The boundary between them is
the Go API, not a process boundary.
- Relying-party applications. Other processes, possibly other teams,
that trust SystemAuth's tokens. Each must accept only tokens addressed to
it.
- Postgres with row-level security. The application's database login is
confined to the tenant set on each transaction.
- SpiceDB. Holds the authorization graph; the application writes it and
asks it for decisions.
- Redis. Shared DPoP replay cache, rate-limit counters and BFF sessions.
- The payment provider and its webhooks. Inbound webhooks are untrusted
until verified; outbound calls carry the provider secret.
- Upstream social identity providers. Assert identities and email
addresses that SystemAuth links to accounts.
Threats and mitigations
The tables use STRIDE: Spoofing, Tampering, Repudiation,
Information disclosure, Denial of service and Elevation of
privilege. "Where" names the package that holds the control.
Edge proxy and host reverse proxy
| STRIDE |
Threat |
Mitigation |
Where |
| S |
A client sets a forwarding header to pose as another address and escape per-address limits or lockouts |
Client addresses come from the configured header only when the request arrives from a trusted proxy network |
session/bff (ClientIPConfig), session/ratelimit |
| T |
A rewritten path makes the DPoP proof's URL differ from the one the server checks, or lets a proof for one URL pass at another |
Proofs are checked against the configured external base URL |
identity/relyingparty (DPoPConfig.BaseURL), session/middleware |
| I |
Codes, keys and invitation tokens in URLs reach tracing and metrics |
Spans and metrics carry the matched route pattern, not the raw URL |
observability, productgraph |
| D |
Credential stuffing and token-endpoint floods |
Edge rate-limit rules on the token, login and sign-up paths; in-app limits with shared counters; password lockout per identifier |
Edge configuration, session/ratelimit, session/bff, identity/security |
| E |
Plain-HTTP hop exposes cookies and tokens |
__Host- cookies are Secure; HSTS on https issuers; production refuses an http issuer |
identity/systemauth, identity/systemauthsvc |
SystemAuth (the authorization server)
| STRIDE |
Threat |
Mitigation |
Where |
| S |
A stale verified email at a social provider signs in to the address's current owner |
An identity links by email only when the provider is authoritative for the address; GitHub uses the primary email only |
identity/systemauth (social directory), identity/oauthclient |
| S |
Someone pre-registers another person's address |
Sign-up requires proof of the address; a changed email becomes unverified |
identity/signup, identity/principal |
| S |
Another site signs a visitor into an account it controls (login CSRF) |
The login-token exchange requires an allowed Origin or Referer |
identity/systemauth (login-token exchange) |
| S |
Guessing passwords, or learning which accounts exist from response time |
Lockout per identifier whether or not the account exists; unknown accounts spend one Argon2id verification |
identity/security, identity/systemauth |
| T |
An authorization code exchanged twice, or a refresh token rotated twice, by concurrent requests |
Invalidation matches only an unused code; rotation retires a token only while it is active; reuse revokes the family |
identity/systemauth (storage), identity/oauth |
| T |
A token bound to a key nobody proved to hold |
Tokens bind only to a verified DPoP proof |
identity/token, identity/dpopproof |
| T |
A replayed DPoP proof against another instance |
A replay cache shared by every instance; production refuses a per-instance cache unless one instance is declared |
identity/dpopproof, identity/systemauthsvc |
| I |
Clickjacking and content sniffing on sign-in and consent pages |
Content security policy with frame-ancestors 'none', X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS |
identity/systemauth (security headers) |
| I |
Open redirect after sign-in or logout |
Redirect targets are checked against configured allowlists and registered redirect URIs |
identity/systemauth (social redirect), identity/oauth |
| I |
An access-change feed or back-channel delivery reveals other organizations' changes |
The feed and deliveries are scoped to each client's organizations |
identity/systemauth (access changes, back-channel) |
| D |
Oversized token requests |
A 1 MB body limit before the form is parsed |
identity/systemauth, identity/oauth |
| E |
Startup in production with insecure defaults: no HMAC secret, bearer-only clients, shared-secret client credentials, per-instance replay cache |
Fail-closed production checks, each with a named waiver logged at startup |
identity/systemauth (production), identity/systemauthsvc |
| E |
A managed principal's session authorizes a client that does not allow managed principals |
The authorize and token endpoints refuse it |
identity/systemauth, identity/managedprincipal |
| E |
Tokens of a deleted or deactivated principal keep working |
Unknown or inactive subjects are inactive and their token family is revoked; access changes refuse older tokens |
identity/systemauth, identity/accesschange, identity/relyingparty (RevocationCache) |
Relying-party applications, the BFF and appauth
| STRIDE |
Threat |
Mitigation |
Where |
| S |
A token issued to another client, or an ID token, used as this application's access token |
VerifyAccessToken requires an accepted client_id, the configured audience and an access-token typ |
identity/relyingparty, session/jwt, multiapp |
| S |
A stolen access token replayed as a bearer token |
Tokens are DPoP-bound; a bound token presented as Bearer is always refused; proofs are single-use |
identity/relyingparty, session/middleware, goauth/dpop |
| S |
A forged JWT makes the client sign a DPoP proof for it |
The cnf binding is read only after the signature and issuer verify |
identity/relyingparty (Client.Transport) |
| S |
A long-lived static API key, whose access does not follow membership changes, used as a second way in |
appauth and relyingparty accept no static API keys; command-line tools use identity/publicclient and automation uses service accounts |
identity/appauth, identity/relyingparty, identity/publicclient, identity/serviceaccount |
| T |
A cross-site request rides the session cookie (CSRF) |
State-changing requests, and proxied requests of any method, must come from an allowed origin; the cookie is __Host-, host-only and SameSite |
session/bff (origin), identity/relyingparty |
| T |
A sibling subdomain sets or overwrites the session cookie |
__Host-sf_session, always host-only |
session/bff |
| T |
Proxy path traversal (/api/../../other) reaches an upstream outside its base path with the user's token |
Dot segments and encoded slashes are refused |
session/bff (proxy) |
| T |
Client-supplied identity headers (X-User-Id) reach upstreams |
Only the configured headers are forwarded; identity, routing and forwarding headers are stripped |
session/bff (proxy) |
| I |
Tokens and DPoP private keys read from a session store |
Sessions are sealed with key IDs, bound to the session and column; Redis off loopback requires TLS and a password |
session/bff (sealer, stores) |
| R |
Logout reports success while the session stays valid |
Logout fails closed when the store fails, and deletes only a session the store returned |
session/bff |
| E |
A session that never ends |
Idle timeout and maximum lifetime |
session/bff |
| E |
A route registered with no actions allows every caller |
CanAll with no actions denies; the middleware constructors panic without actions |
authz |
| E |
A resource named in the request checked against the caller's role in another organization |
The resource's organization comes only from the extractor |
authz |
| E |
A principal authorized for no entity gets an unrestricted query policy |
PolicyBuilder.Build returns ErrNoAuthorizedEntities; every entity and field has a concrete resource |
authzguardsql |
Organizations, invitations, SCIM and delegation
| STRIDE |
Threat |
Mitigation |
Where |
| S |
A SCIM credential reads or changes another tenant's users |
Each request is bound to the credential's organization; requests without a scope are refused |
identity/scim |
| S |
SCIM rewrites the login email of a user who also belongs elsewhere (account takeover) |
A tenant changes email, name and photo only for users it provisioned that belong to no other organization |
identity/scim |
| T |
An unsupported SCIM filter matches every user |
Such filters fail with invalidFilter |
identity/scim |
| T |
Invite accept racing a revoke still creates the membership |
Every invite transition is conditional on the invite being pending; acceptance requires the inviter's current authority |
identity/invite |
| I |
Invitations listed for an unverified email claim |
Listing and answering require a verified email |
identity/invite |
| I |
SCIM or API responses carry database error text or password hashes |
Unexpected errors are logged and answered generically; passwords are hashed and never returned |
identity/scim |
| E |
An admin adds another tenant's managed principal without consent |
Direct adds need APIConfig.DirectAdd or platform administration; refusals are indistinguishable |
identity/organization |
| E |
SCIM suspends or deletes an organization's owner, or restores a former admin's role |
Owners are refused with 403; a restored membership gets the default role |
identity/scim, identity/organization |
| E |
Any principal with can_delegate takes over another organization's agent |
ErrAlreadyDelegated and ErrCrossOrganization |
identity/delegation |
Agents and AAuth issuers
| STRIDE |
Threat |
Mitigation |
Where |
| S |
An agent provider claims another domain's agents |
An agent token is accepted only when the agent's domain is its issuer's host; issuers are limited to TrustedIssuers and the allowed issuer kind |
identity/requestprincipal/aauthrs |
| T |
A request signed for another resource replayed here |
The signature's authority defaults to this resource's host |
identity/requestprincipal/aauthrs |
| E |
Issuer metadata fetches reach internal addresses |
The resource fetches through an egress-restricted HTTP client the application supplies |
identity/requestprincipal/aauthrs |
Postgres with RLS
| STRIDE |
Threat |
Mitigation |
Where |
| T |
SQL injection through table, column, policy, role or schema names |
Identifiers are validated and quoted |
rls, multiapp |
| I |
A pooled connection keeps another application's search_path |
SET LOCAL inside a transaction |
multiapp |
| E |
The application login bypasses RLS or changes the schema |
Separate logins for the application and for migrations; the tenant is set per transaction |
rls, operator configuration |
| T |
Concurrent seat assignments overrun a seat limit |
A conditional increment inside a transaction |
marketplace |
SpiceDB (the authorization graph)
| STRIDE |
Threat |
Mitigation |
Where |
| E |
A deactivated member, deleted user or demoted collaborator keeps access in the graph |
Deactivation, demotion and deletion remove relationships before committing |
identity/organization, identity/principal, identity/scim |
| E |
A failed graph write after a committed revocation leaves access granted; a late grant undoes a revocation |
Revocations remove graph access first; grants re-read the rows under graph locks |
identity/organization, identity/approle, identity/graphlock, authz/mode |
| E |
A connection's creator keeps rights after leaving the organization |
Creator rights require current membership of the owning organization |
connections |
| S |
A local process writes relationships to the embedded SpiceDB server |
An in-memory listener and a random 32-byte key |
authz/spicedb |
Redis
| STRIDE |
Threat |
Mitigation |
Where |
| I |
Session IDs and sealed sessions cross the network in clear text |
A non-loopback server requires TLS with certificate verification and a password |
session/bff (omnistorage store) |
| T |
One application's session index shared with another's |
A site ID is required and scopes the key prefix |
session/bff |
| D |
A parallel burst counted as one request |
Each sliding-window entry has a unique random member |
session/ratelimit |
Payment provider and webhooks
| STRIDE |
Threat |
Mitigation |
Where |
| S |
A forged webhook grants a license or plan |
Signatures are verified with a configured secret, fail closed without one, and bodies are size-limited |
billing (webhook handler) |
| T |
A replayed or reordered event applies twice or rolls state back |
Processed event IDs under a unique constraint; a per-account lock across instances; state re-read from the provider |
billing, billing/pglock |
| T |
A failed authorization-graph sync leaves a paid entitlement unapplied |
Every apply writes the graph again, so the provider's redelivery repairs it |
billing (sink) |
| I |
Checkout or portal redirects to a request-supplied URL (open redirect) |
Redirect URLs are configured on the server; hosts are allowlisted |
billing (checkout) |
| I |
Error detail in webhook or checkout responses |
Fixed messages in responses; causes are logged |
billing |
| E |
A member starts a checkout or opens the portal for another organization |
The organization comes from the caller's session, an organization-manage check runs, and the customer is found server-side |
billing (checkout) |
Connections and the credential vault
| STRIDE |
Threat |
Mitigation |
Where |
| I |
One backup holds both the key ring and the root key that decrypts it |
Root keys in or under the keys directory are refused |
keys |
| I |
Provider requests reach internal or cloud metadata addresses (SSRF) |
An egress policy allows only public addresses over https; the development allowlist never admits link-local or metadata addresses |
identity/systemauthsvc (connections), connections |
| D |
Oversized plaintext sealed into the store |
The sealer and the row key ring refuse plaintext over 64 MiB |
session/bff, identity/relyingparty/pgstore |
Upstream social identity providers
| STRIDE |
Threat |
Mitigation |
Where |
| S |
A provider asserts an email it is not authoritative for |
Linking by email only for authoritative providers and domains |
identity/systemauth (social directory) |
| T |
The OAuth client state cookie is set without Secure or with a weak SameSite |
__Host-sf_oauth_client_state, Secure, HttpOnly, SameSite=Lax |
identity/oauthclient |
| T |
A crafted state or redirect parameter sends the user elsewhere |
State is bound to the browser and redirects are allowlisted |
identity/oauthclient, identity/systemauth (social redirect) |
Audit and repudiation across boundaries
| STRIDE |
Threat |
Mitigation |
Where |
| R |
Changes to managed principals and SCIM tokens cannot be traced |
An audit emitter records managed-principal actions and SCIM token issue, rotation, revocation and failed authentication |
identity/systemauthsvc, contract/audit |
| T |
A token for one hosted application's audit stream repoints another's |
The audit stream state belongs to each contract.API |
contract |
| R |
Access changes are not visible to applications |
Every change to a principal's access is recorded and fed to clients, which refuse older tokens |
identity/accesschange, identity/relyingparty |
Residual risks and accepted trade-offs
- The signing key shares the process with application code when
SystemAuth is embedded. A vulnerability in any embedded application, a
debug endpoint or a heap dump can expose the key that signs every token.
This is accepted for hosts that run a small set of first-party
applications from one codebase; prefer the standalone binary when
third-party code runs in the host. See
Embedding SystemAuth.
- Per-instance stores are safe only on one instance. In-memory replay
caches, rate-limit counters and session stores are accepted when exactly
one instance runs. Production refuses a per-instance DPoP replay cache
unless
dpop.single_instance declares it; rate-limit counters and other
stores rely on the operator following the
production checklist.
- Rate limiting depends on the client-address rule. SystemAuth applies
a default per-address limit to every route, tighter policies on the
token, login, sign-up and BFF sign-in paths, outcome limits on failed
client authentication and social login, and a limit for signed-in
callers (
session/ratelimit, session/clientaddr). Per-address limits
are only as good as the trusted-proxy configuration: behind a reverse
proxy that is not declared in client_address, every visitor shares the
proxy's address. With several instances the counters must be shared
(rate_limit.redis_url); per-instance counters only loosen the limits.
Edge rate-limit rules remain a useful first layer.
- No external penetration test has been done yet. The controls above
come from internal review and the security fixes recorded in the
changelog. An independent test is a recommended next step.
- Unbound bearer tokens remain possible by waiver. Clients registered
with
allow_bearer_tokens, and applications that set AllowBearerTokens,
accept tokens that work for whoever holds a copy. Production refuses them
unless waived, and each waiver is logged at startup.
- The
identity/apikey package and the session/middleware API key
middleware remain for services outside the relying-party stack that
manage keys of their own. Keys checked there carry the scopes recorded
when they were created, and their owner's status is checked on every
request, but they do not follow membership changes the way access tokens
do.
- Upstream providers are trusted for what they assert. A compromised
social identity provider, AAuth issuer or payment provider can assert
false identities or payment facts within the limits above.
Keeping the model current
- Every security fix updates the model. A fix recorded under
security in the changelog also adds or changes a row here, in the same
release.
- New trust boundaries are added before they ship. A new external
service, store, protocol or credential type gets its boundary and threats
written down in the change that introduces it.
- Each minor release reviews the residual risks, removing those that
were closed and adding accepted trade-offs that were introduced.
- The production checklist follows the assumptions. An assumption that
the operator must uphold appears as an item in the
production checklist.