Authentication

Three auth modes, one user.

The same user account is reachable through three different mechanisms. Pick the one that fits the surface you're building. They coexist — a single user can have a session cookie, a JWT pair, and several Personal Access Tokens at once.

All docs / Authentication

At a glance

Mode Where Lifetime Issued by
SessionMarketing pagesCookie session/login/ form
JWTReact SPA, mobile, custom clients15m access · 14d refresh (rotated)POST /auth/login/email/
PATScripts, CI, server-to-serverUntil revokedPOST /me/access-tokens/

Session (templated marketing site)

Used by the server-rendered pages — landing, pricing, the signup/login forms — and as the bootstrap for the React SPA. After a user logs in via the form, the SPA exchanges the session cookie for a JWT pair (see JWT) so all subsequent API calls go through the token interceptor. The cookie is HttpOnly, SameSite=Lax.

JWT (browser SPAs & mobile)

SimpleJWT-issued. The access token is short-lived (15 minutes) so a leaked one ages out fast. The refresh token is longer (14 days), single-use, and rotated — once you've refreshed, the old refresh becomes invalid.

Login

POST /api/v1/auth/login/email/
Content-Type: application/json

{"email":"you@example.com","password":"very-long-password"}

# 200 OK
{
  "tokens": { "access": "eyJ...", "refresh": "eyJ..." },
  "user":   { "id": "...", "email": "...", "username": "..." }
}

Authenticated request

GET /api/v1/portfolio/
Authorization: Bearer <access-token>

Refresh on 401

POST /api/v1/auth/refresh/
Content-Type: application/json

{"refresh":"<refresh-token>"}

# 200 OK
{ "access": "eyJ...", "refresh": "eyJ..." }   # both rotated

Storage: an httpOnly cookie is the safest place for the refresh token; localStorage is acceptable for the access token if you balance the XSS risk against the operational cost. The reference SPA stores both in localStorage and pivots on a 401 response interceptor — see PortfolioHubFrontend/src/lib/api-client.ts for the pattern.

Bridge from a Django session

The SPA uses this on first load — after a user logs in via the marketing form, this exchange swaps the session cookie for a JWT pair so every subsequent call goes through the standard token flow.

POST /api/v1/auth/from-session/
Cookie: sessionid=…

# 200 OK — same shape as /auth/login/email/
{ "tokens": { "access": "eyJ...", "refresh": "eyJ..." }, "user": { ... } }

Logout

POST /api/v1/auth/logout/
Authorization: Bearer <access-token>
Content-Type: application/json

{"refresh":"<refresh-token>"}   # blacklists the JWT AND flushes the session

Personal Access Tokens (programmatic)

For CI, sync jobs, scripts you don't want to re-authenticate every 14 days. PATs are opaque bearer tokens with a stable prefix (phub_pat_…) and a random suffix.

Issue

POST /api/v1/me/access-tokens/
Authorization: Bearer <jwt-access>
Content-Type: application/json

{"name":"ci-content-sync","expires_at":"2026-12-31T00:00:00Z"}   # expires_at optional

# 201 Created
{
  "id": "uuid",
  "name": "ci-content-sync",
  "prefix": "phub_pat_xxxx",
  "token": "phub_pat_xxxx....."   # SHOWN ONCE — store immediately
}

Use

POST /api/v1/portfolio/projects/
Authorization: Bearer phub_pat_xxxx.....
Content-Type: application/json

{"title":"My Project","summary":"Synced from CI"}

List & revoke

GET    /api/v1/me/access-tokens/                # never returns plaintext
DELETE /api/v1/me/access-tokens/<id>/   # revokes the token immediately

Storage: server stores only the SHA-256 hash and the prefix. Logs and audit trails reference the prefix, so a leaked token in the logs is still partial. Revoke any token the moment it leaks.

How the server picks a mode

DRF tries authentication classes in order: PAT → JWT → Session. PAT is first because it's identified cheaply by the phub_pat_ prefix, so a JWT-shaped header isn't accidentally consumed by the PAT class. The other order would 401 PATs.

Need a refresher on what to do when a request is rejected? Read Errors next — every 4xx response carries a stable error.code you can branch on.