At a glance
| Mode | Where | Lifetime | Issued by |
|---|---|---|---|
| Session | Marketing pages | Cookie session | /login/ form |
| JWT | React SPA, mobile, custom clients | 15m access · 14d refresh (rotated) | POST /auth/login/email/ |
| PAT | Scripts, CI, server-to-server | Until revoked | POST /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.