# Authentication This page documents the calls a client makes to sign in and stay signed in: what to send, what comes back, and what each failure mode means. For the design behind these endpoints. Token lifetimes, claims, why rotation is implemented by hand, why personal access tokens and organization API keys exist alongside the JWT. See [Architecture: Authentication](../architecture/authentication.md), which this page does not repeat or contradict. For a guided walkthrough of picking a sign-in path locally, see [First sign-in](../getting-started/first-sign-in.md). All three interactive sign-in paths below, Google OAuth, Google ID token, and both magic-link verify endpoints, mint tokens through the same `OrgAwareRefreshToken.for_user_and_org()` helper and are public: `permission_classes = []`, `authentication_classes = []` (`backend/common/views/auth_views.py`). None of them require an existing token to call. Everything on this page signs in somebody on **your** side of the org. A customer signing in to the support portal uses a different set of endpoints and a different credential entirely, and neither realm's token works against the other; see [Customer portal](customer-portal.md). ## Google OAuth (web) `POST /api/auth/google/callback/` (`GoogleOAuthCallbackView`, `backend/common/views/auth_views.py:52-198`) completes a PKCE authorization-code exchange for the SvelteKit frontend. Request body: ```json { "code": "", "code_verifier": "", "redirect_uri": "" } ``` `redirect_uri` is taken from this field, not from any server-side setting; there is no server-side redirect-URI setting to configure. See [Google OAuth](../self-hosting/google-oauth.md) for the settings that do matter. The view exchanges these with Google, decodes the returned ID token, and rejects the sign-in outright if Google's own `email_verified` claim is not `true`. An unverified Google address never reaches the database. On success: ```json { "access_token": "", "refresh_token": "", "user": {"id": "", "email": "user@example.com"} } ``` The token is minted with no org claim (`OrgAwareRefreshToken.for_user_and_org(user, None)`, `:190`), both Google flows always hand back an org-less token (the magic-link flows differ; see [Magic links](#magic-links) below), so the client's next call is normally [switching organization](#switching-organization). Failure responses: `400` for a missing `code`/`code_verifier`/`redirect_uri`, an invalid ID token, a missing email, or an unverified email; `502` if Google's token endpoint could not be reached at all (`requests.RequestException`, `:110-114`); `403` if the resulting user account is deactivated. ## Google ID token (mobile) `POST /api/auth/google/` (`GoogleIdTokenView`, `backend/common/views/auth_views.py:201-319`) is the mobile equivalent: the client has already obtained a Google ID token itself and hands it over directly, rather than a code the server exchanges. Request body: ```json {"idToken": ""} ``` The server verifies it against Google (`google.oauth2.id_token.verify_oauth2_token`, checked against `GOOGLE_CLIENT_ID`) and applies the same `email_verified` check as the web flow. On success: ```json { "JWTtoken": "", "refresh_token": "", "user": {"id": "", "email": "user@example.com", "name": "user", "profileImage": ""}, "organizations": [{"id": "", "name": "Acme", "role": "ADMIN"}] } ``` Note the access token field here is `JWTtoken`, not `access_token`. The two Google flows do not share a response shape, so a client that supports both cannot read the access token the same way from each. This response also includes `organizations` directly (the caller's existing memberships), which the web callback response above does not; a mobile client can offer org selection without an extra `GET /api/auth/me/` round trip. Failure responses mirror the web flow: `400` for a missing or invalid `idToken`, a missing email, or an unverified email; `403` for a deactivated account. ## Magic links Passwordless sign-in, in two deliveries, across three endpoints: `POST /api/auth/magic-link/request/` (`MagicLinkRequestView`, `backend/common/views/auth_views.py:586-655`): ```json {"email": "user@example.com", "delivery": "link"} ``` `delivery` is optional and defaults to `"link"`; the only other value is `"code"`. This endpoint **always returns `200`** with the same body, regardless of whether the address has an account, whether it failed validation (a malformed or disposable-domain email), or whether it was rate limited (five tokens per address per hour), every one of those cases falls through to the same generic response: ```json {"message": "If this email is valid, you will receive a sign-in link."} ``` This is deliberate, see [First sign-in](../getting-started/first-sign-in.md#magic-links), and means a client cannot distinguish "sent" from "rejected" from this response; there is no `400` to handle here at all. A generated token expires after 10 minutes. The one other answer is `429`, from the per-client and site-wide limits in `backend/common/throttles.py` (`MAGIC_LINK_THROTTLE_IP`, default `30/hour`, and `MAGIC_LINK_THROTTLE_GLOBAL`, default `300/hour`). They run before the email is read, so a `429` says nothing about the address either. `POST /api/auth/magic-link/verify/` (`MagicLinkVerifyView`, `backend/common/views/auth_views.py:658-768`) exchanges the token from a link-delivery email: ```json {"token": "<64-character hex token from the emailed link>"} ``` `POST /api/auth/magic-link/verify-code/` (`MagicLinkVerifyCodeView`, `backend/common/views/auth_views.py:771-899`) exchanges a code-delivery OTP instead: ```json {"email": "user@example.com", "code": "123456"} ``` A code is checked under `select_for_update` so two concurrent verify attempts can't both observe the same attempt count, and is burned (marked used) after 5 failed attempts even if the correct code is submitted later. Both verify endpoints return the same success shape: ```json { "access_token": "", "refresh_token": "", "user": { "id": "", "email": "user@example.com", "profile_pic": "", "is_active": true, "organizations": [{"id": "", "name": "Acme", "role": "ADMIN", "is_organization_admin": true, "has_sales_access": true, "has_marketing_access": true}] } } ``` with one addition: if the user has **any** active org membership, the token is minted bound to one of them and the response also includes: ```json {"current_org": {"id": "", "name": "Acme"}} ``` This is not gated on having exactly one membership; `Profile.objects.filter(user=user, is_active=True).first()` (`backend/common/views/auth_views.py:740,745-746` for verify, `:874,878-879` for verify-code) picks whichever active profile sorts first, and `Profile`'s default ordering is `("-created_at",)` (`backend/common/models.py:238`), so for a user in more than one org this binds to their **most recently created** membership, not necessarily the one they intend to use next. A client for a user who might belong to multiple orgs cannot assume magic-link verification always leaves org selection to a later step. It has to check whether `current_org` is present and let the user switch away from it if it's the wrong one, via [switching organization](#switching-organization). If there is no active org membership at all, the token comes back org-less, same as the OAuth flows. Failure responses: `400` with `{"error": "Invalid request"}` if the request body itself fails validation (missing token, malformed email, code not exactly 6 digits); `400` with `{"error": "Invalid or expired link"}` / `{"error": "Invalid or expired code"}` if the token or code doesn't match an unused, unexpired row; `403` for a deactivated account. Verifying creates the user if the email has never signed in before, exactly like the OAuth flows do. ## Using an access token Send the access token as `Authorization: Bearer `. It is valid for one hour. See [Token model](../architecture/authentication.md#token-model) for why, and what happens after it expires. `GET /api/auth/me/` (`MeView`, `backend/common/views/auth_views.py:322-336`) returns the signed-in user and every organization they have an active profile in: ```json { "id": "", "email": "user@example.com", "profile_pic": "", "is_active": true, "organizations": [ {"id": "", "name": "Acme", "role": "ADMIN", "is_organization_admin": true, "has_sales_access": true, "has_marketing_access": true} ] } ``` Two things worth knowing about this specific endpoint: it sets `authentication_classes = [JWTAuthentication]` explicitly (`:327`), so unlike most endpoints it does not also accept a personal access token or an organization API key, only a JWT works here. And its exact path is one of a short list `GetProfileAndOrg` skips org-context resolution for entirely (`backend/common/middleware/get_company.py:29-38`). The middleware returns immediately without trying to set `request.profile`/`request.org`, so this endpoint never requires an `org_id` claim. That is what makes it work with the org-less token an org-less sign-in above produces, and exactly what makes it useful for letting a freshly-authenticated client list organizations to choose from. ## Refreshing `POST /api/auth/refresh-token/` (`OrgAwareTokenRefreshView`, `backend/common/views/auth_views.py:339-448`) exchanges a refresh token for a new access/refresh pair: ```json {"refresh": ""} ``` ```json {"access": "", "refresh": ""} ``` Note the field names here, `access` and `refresh`, do not match `access_token`/`refresh_token` used by the sign-in endpoints above; this is the one response on this page that uses the shorter pair. Failure responses: `400` if `refresh` is missing; `401` if the token is expired, malformed, or already blacklisted (`TokenError`); `403` if the user account has been deactivated since the token was issued; `403` if the token carries an `org_id` the user no longer has an active profile in. See [Refresh and rotation](../architecture/authentication.md#refresh-and-rotation) for what happens to the old token on a successful call, and why. ## Switching organization `POST /api/auth/switch-org/` (`OrgSwitchView`, `backend/common/views/auth_views.py:451-583`) moves an authenticated caller to a different org and re-mints both tokens bound to it. Unlike most endpoints it only requires `IsAuthenticated`, not org context, because it has to work for a token that has none yet: ```json { "org_id": "", "refresh": "" } ``` ```json { "access_token": "", "refresh_token": "", "current_org": {"id": "", "name": "Acme"}, "profile": {"id": "", "role": "ADMIN", "is_organization_admin": true} } ``` `refresh` is optional; when it is sent and belongs to the caller, it is blacklisted as part of the switch so the org-less (or previous-org) token stops working immediately rather than staying valid for the rest of its 14-day life. See [Organization switching](../architecture/authentication.md#organization-switching) for why the ownership check on that field matters. Failure responses: `400` if `org_id` is missing or not a valid UUID; `403` if the caller has no active profile in the requested org. ## Local development For local development against a running backend with `DEBUG=True`, the fastest way to obtain a token pair is the `devlogin` management command rather than any of the endpoints above. It needs no OAuth provider and no outbound email. It refuses to run at all unless `DEBUG` is `True`. See [First sign-in: Local development](../getting-started/first-sign-in.md) for its exact invocation, flags, and org-resolution behavior; this page's endpoints are what `devlogin` is a shortcut around, and the tokens it prints work identically against every endpoint described above.