Overview
Authorization in Polar is split into two layers:- Authentication (
Authenticator): “Who are you?” — validates tokens/sessions, resolves the subject (User, Organization, Customer, Member), extracts token scopes. - Authorization (
server/polar/authz/): “Are you allowed to do this?” — checks org membership, evaluates policies, enforces access control.
Authenticator dependency. Authorization happens via PolicyGuard dependencies that resolve resources and check policies before the endpoint body runs.
PolicyGuard
A PolicyGuard is a FastAPI dependency factory that combines resource resolution and authorization into a single step. When an endpoint declares a PolicyGuard dependency, the framework:- Authenticates the subject
- Resolves the resource from the path parameter
- Checks that the subject has access to the owning organization
- Evaluates the policy function
- Returns an
AuthzContext(orAuthorizedAccount, etc.) containing both the resource and the auth subject
Error behavior
| Scenario | HTTP status | Reason |
|---|---|---|
| No valid credentials | 401 Unauthorized | Authenticator rejects the request |
| Token lacks required scopes | 403 Forbidden | Authenticator scope check fails |
| Resource doesn’t exist | 404 Not Found | Resource not found in database |
| Subject not a member of the org | 404 Not Found | Returns 404 (not 403) to avoid leaking existence |
| Subject is a member but policy denied | 403 Forbidden | Policy returned a denial reason |
Guard variants
There are three PolicyGuard variants for different resource types:| Guard | Resolves by | Use case |
|---|---|---|
OrgPolicyGuard | {id} → Organization | Org-scoped endpoints (/organizations/{id}/...) |
AccountPolicyGuard | {id} → Account → owning Organization | Account endpoints (/accounts/{id}) |
PayoutAccountPolicyGuard | {id} → PayoutAccount → owning Organization | Payout account endpoints (/payout-accounts/{id}) |
Typed dependencies
Each guard is paired with a policy and exposed as a named dependency inserver/polar/authz/dependencies.py. Endpoints depend on these names rather than wiring guards and policies themselves. For example:
server/polar/authz/dependencies.py for the full list.
Policies
Policies are async functions inserver/polar/authz/policies/. Each policy receives the database session, auth subject, and organization, and returns either True (allowed) or a denial reason string.
NotPermitted exception and appears in the 403 response body.
Where policies live
Policies are grouped by the resource area they protect — one file per area inserver/polar/authz/policies/ (e.g. finance, members, organization). A file typically exposes can_read/can_write or a more specific verb when the operation has its own rules (e.g. can_delete, can_manage). New areas get a new file rather than overloading an existing one.
Org-scoped data access
Repositories are pure data access — they don’t check authentication or authorization. Instead, they accept explicit org IDs:get_accessible_org_ids returns the set of organization IDs the subject can access:
- For a User: all orgs they’re a member of (via
UserOrganization), optionally filtered by a requiredpermission - For an Organization token: just that org’s ID (permission filters are bypassed — the token represents the org itself)
- For anything else: empty set
permission is provided, user subjects are restricted to organizations where their UserOrganization.role grants that permission. This is useful for service-layer data access that needs to enforce role-based permissions without using a PolicyGuard:
User-personal endpoints
Some endpoints don’t operate on an organization — they act on the authenticated user themselves. Examples: own profile (/users/me), Personal Access Token management, OAuth identity linking, email update, per-user integration callbacks. There’s no organization to resolve, so no membership check or org-scoped policy applies.
For these, use one of the user-personal Authorize* aliases from server/polar/authz/dependencies.py:
Two prefixes: Web* vs not
User-personal aliases come in two flavours that differ in which kinds of authenticated User subjects they accept:
AuthorizeWeb{User,Payouts}{Read,Write}— User via web session only. Rejects API tokens (PATs, OATs, OAuth2 access tokens). Use for browser/dashboard-only flows. This is the right default for almost every user-personal endpoint.Authorize{User}{Read,Write}— Any User subject (web session, PAT, OAuth2 access token) with the appropriate scope. Use only for endpoints that legitimately need to accept API tokens — for example, the mobile app callingDELETE /v1/users/mewith an OAuth2 bearer.
_read or _write scope (write implies read); write aliases require the _write scope. Available aliases today:
| Alias | Subjects | Required scopes | Use case |
|---|---|---|---|
AuthorizeWebUserRead | Web session only | user:read or user:write | Reading the user’s own state from the dashboard |
AuthorizeWebUserWrite | Web session only | user:write | Mutating the user’s own account from the dashboard |
AuthorizeWebPayoutsRead | Web session only | payouts:read or payouts:write | Reading the user’s payout accounts |
AuthorizeWebPayoutsWrite | Web session only | payouts:write | Creating a payout account |
AuthorizeUserRead | Any User (web/PAT/OAuth2) | user:read or user:write | API-token-accessible user reads |
AuthorizeUserWrite | Any User (web/PAT/OAuth2) | user:write | API-token-accessible user writes (e.g. mobile delete-account) |
authz/dependencies.py if a new endpoint needs a scope combination that isn’t listed. Endpoints that resolve an organization from a path parameter belong with the OrgPolicyGuard family above, not here — even when the operation is read-only.
Two layers, same model
The split between authentication and authorization still applies:WebUserSession(inserver/polar/auth/dependencies.py) — pure authentication for theWeb*aliases. Accepts only web-session users; rejects API tokens.Authorize*aliases (above) — the authorization layer. Add the scope check, and are the place to attach future cross-cutting per-user checks (account suspended, identity not verified, MFA required, …) without touching endpoint signatures.
Authorize* alias, never on WebUserSession directly.
Why scopes here too
The scope check on user-personal endpoints is what enforces read-only impersonation. Backoffice impersonation creates a user session with onlyREAD_ONLY_SCOPES (the _read set). Resource endpoints already block writes via their org-scoped guards’ required_scopes. Mirroring that on user-personal endpoints means an impersonating admin can read profile/PATs/OAuth state but can’t mutate it — same mechanism, single invariant: any endpoint declaring a _write scope is automatically off-limits to impersonation, regardless of which prefix is used.
Scopes
Token scopes are checked by theAuthenticator as a first-pass filter. They represent what the token is allowed to do, not what the user is allowed to do.
- Web sessions have all scopes — the user’s permissions are determined entirely by org membership and policies
- PATs (Personal Access Tokens) have user-selected scopes — a CI token might only have
products_read - OATs (Organization Access Tokens) have org-selected scopes
required_scopes appropriate to their resource area. For example, AuthorizeFinanceRead requires transactions_read, transactions_write, payouts_read, or payouts_write.
Adding a new policy-guarded endpoint
- Choose or create a policy function in
server/polar/authz/policies/ - Create a typed dependency in
server/polar/authz/dependencies.pyusing the appropriate guard variant - Use the dependency in the endpoint — declare it as a parameter, access
.organizationand.auth_subjectfrom the result

