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, which this page does not repeat or contradict. For a guided walkthrough of picking a sign-in path locally, see First sign-in.
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.
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:
{
"code": "<authorization code from Google's redirect>",
"code_verifier": "<PKCE verifier the frontend generated>",
"redirect_uri": "<the redirect_uri used in the initial authorize request>"
}
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 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:
{
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"user": {"id": "<uuid>", "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 below), so the client's next call is normally
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:
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:
{
"JWTtoken": "<jwt access token>",
"refresh_token": "<jwt>",
"user": {"id": "<uuid>", "email": "user@example.com", "name": "user", "profileImage": ""},
"organizations": [{"id": "<uuid>", "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):
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:
This is deliberate, see First sign-in, 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:
POST /api/auth/magic-link/verify-code/ (MagicLinkVerifyCodeView,
backend/common/views/auth_views.py:771-899) exchanges a code-delivery OTP instead:
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:
{
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"user": {
"id": "<uuid>",
"email": "user@example.com",
"profile_pic": "",
"is_active": true,
"organizations": [{"id": "<uuid>", "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:
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. 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 <access_token>. It is valid for one hour. See
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:
{
"id": "<uuid>",
"email": "user@example.com",
"profile_pic": "",
"is_active": true,
"organizations": [
{"id": "<uuid>", "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:
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 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:
{
"org_id": "<uuid of an org the caller has an active profile in>",
"refresh": "<optional: the refresh token being replaced>"
}
{
"access_token": "<jwt bound to org_id>",
"refresh_token": "<jwt bound to org_id>",
"current_org": {"id": "<uuid>", "name": "Acme"},
"profile": {"id": "<uuid>", "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 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 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.