Skip to main content

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.
These are separate concerns. Authentication happens via FastAPI’s 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:
  1. Authenticates the subject
  2. Resolves the resource from the path parameter
  3. Checks that the subject has access to the owning organization
  4. Evaluates the policy function
  5. Returns an AuthzContext (or AuthorizedAccount, etc.) containing both the resource and the auth subject
If any step fails, the appropriate HTTP error is raised before the endpoint body executes.
from polar.authz.dependencies import AuthorizeFinanceRead

@router.get("/{id}/account")
async def get_account(
    authz: AuthorizeFinanceRead,
    session: AsyncReadSession = Depends(get_db_read_session),
) -> Account:
    # authz.organization is already resolved and access-checked
    # authz.auth_subject is the authenticated subject
    account = await account_service.get_by_organization(
        session, authz.organization.id
    )
    if account is None:
        raise ResourceNotFound()
    return account

Error behavior

ScenarioHTTP statusReason
No valid credentials401 UnauthorizedAuthenticator rejects the request
Token lacks required scopes403 ForbiddenAuthenticator scope check fails
Resource doesn’t exist404 Not FoundResource not found in database
Subject not a member of the org404 Not FoundReturns 404 (not 403) to avoid leaking existence
Subject is a member but policy denied403 ForbiddenPolicy returned a denial reason

Guard variants

There are three PolicyGuard variants for different resource types:
GuardResolves byUse case
OrgPolicyGuard{id} → OrganizationOrg-scoped endpoints (/organizations/{id}/...)
AccountPolicyGuard{id} → Account → owning OrganizationAccount endpoints (/accounts/{id})
PayoutAccountPolicyGuard{id} → PayoutAccount → owning OrganizationPayout account endpoints (/payout-accounts/{id})

Typed dependencies

Each guard is paired with a policy and exposed as a named dependency in server/polar/authz/dependencies.py. Endpoints depend on these names rather than wiring guards and policies themselves. For example:
AuthorizeFinanceRead    # OrgPolicyGuard + finance.can_read
AuthorizeMembersManage  # OrgPolicyGuard + members.can_manage (User-only)
AuthorizeAccountWrite   # AccountPolicyGuard + finance.can_write
There are also “always-allow” variants for endpoints that only need authentication, scope check, and org membership — no further policy. They exist so endpoints can still benefit from centralized resource resolution (the guard fetches the org and 404s non-members) without inventing a trivial policy. See server/polar/authz/dependencies.py for the full list.

Policies

Policies are async functions in server/polar/authz/policies/. Each policy receives the database session, auth subject, and organization, and returns either True (allowed) or a denial reason string.
# server/polar/authz/policies/finance.py
async def can_read(
    session: AsyncReadSession,
    auth_subject: AuthSubject[User | Organization],
    organization: Organization,
) -> PolicyResult:
    if is_organization(auth_subject):
        return True
    if is_user(auth_subject):
        if organization.account_id is None:
            return "Organization has no account"
        if await account_service.is_user_admin(
            session, organization.account_id, auth_subject.subject
        ):
            return True
        return "Only the account admin can access financial information"
    return "Not permitted"
The denial reason string is passed through to the 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 in server/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:
# Repository — pure data access
class ProductRepository(RepositoryBase[Product]):
    def get_by_org_ids_statement(self, org_ids: set[UUID]) -> Select[tuple[Product]]:
        return self.get_base_statement().where(
            Product.organization_id.in_(org_ids)
        )
Services resolve which orgs the subject can access, then pass the IDs to the repository:
# Service — resolves org access, delegates to repository
from polar.authz.service import get_accessible_org_ids

class ProductService:
    async def list(self, session, auth_subject, ...):
        org_ids = await get_accessible_org_ids(session, auth_subject)
        repository = ProductRepository.from_session(session)
        statement = repository.get_by_org_ids_statement(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 required permission
  • For an Organization token: just that org’s ID (permission filters are bypassed — the token represents the org itself)
  • For anything else: empty set
When 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:
org_ids = await get_accessible_org_ids(
    session, auth_subject, permission=OrganizationPermission.finance_read
)

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:
from polar.authz.dependencies import AuthorizeWebUserRead, AuthorizeWebUserWrite

@router.get("/me")
async def get_authenticated(auth_subject: AuthorizeWebUserRead) -> User:
    return auth_subject.subject

@router.patch("/me")
async def update_authenticated(auth_subject: AuthorizeWebUserWrite, ...) -> User:
    ...

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 calling DELETE /v1/users/me with an OAuth2 bearer.
In both flavours, read aliases accept either the matching _read or _write scope (write implies read); write aliases require the _write scope. Available aliases today:
AliasSubjectsRequired scopesUse case
AuthorizeWebUserReadWeb session onlyuser:read or user:writeReading the user’s own state from the dashboard
AuthorizeWebUserWriteWeb session onlyuser:writeMutating the user’s own account from the dashboard
AuthorizeWebPayoutsReadWeb session onlypayouts:read or payouts:writeReading the user’s payout accounts
AuthorizeWebPayoutsWriteWeb session onlypayouts:writeCreating a payout account
AuthorizeUserReadAny User (web/PAT/OAuth2)user:read or user:writeAPI-token-accessible user reads
AuthorizeUserWriteAny User (web/PAT/OAuth2)user:writeAPI-token-accessible user writes (e.g. mobile delete-account)
Add a new alias to 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 (in server/polar/auth/dependencies.py) — pure authentication for the Web* 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.
Endpoints depend on the 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 only READ_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 the Authenticator 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
PolicyGuard dependencies declare 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

  1. Choose or create a policy function in server/polar/authz/policies/
  2. Create a typed dependency in server/polar/authz/dependencies.py using the appropriate guard variant
  3. Use the dependency in the endpoint — declare it as a parameter, access .organization and .auth_subject from the result
# 1. Policy
async def can_do_thing(session, auth_subject, organization) -> PolicyResult:
    # your authorization logic
    return True

# 2. Typed dependency
AuthorizeDoThing = Annotated[
    AuthzContext[User | Organization],
    Depends(OrgPolicyGuard(can_do_thing)),
]

# 3. Endpoint
@router.post("/{id}/thing")
async def do_thing(authz: AuthorizeDoThing, ...) -> ...:
    organization = authz.organization
    ...