Auth API
Sign up, exchange an OAuth code for tokens, read your own profile, and mint a
WebSocket ticket. All routes use snake_case request bodies (per the
API-wide convention).
For the account lifecycle (signup → pending → approved), the reCAPTCHA gate, and how
custom:approved locks the rest of the API, see Authentication, Signup &
Approval. This page is the endpoint-by-endpoint wire contract; the
authoritative spec is specs/api/auth.md in the repo.
Route summary
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/auth/signup | None | Sign up via Cognito + reCAPTCHA |
POST | /api/auth/callback | None | Exchange an OAuth code for tokens |
GET | /api/auth/me | JWT | Get your profile + approval status |
POST | /api/ws/ticket | JWT | Mint a WebSocket ticket |
How every JWT route is guarded
Every route marked "JWT" above — and every other protected route in the API — goes through one shared Lambda authorizer before your handler ever runs:
- Your
Authorization: Bearer <token>header is verified against Cognito's public keys (signature, expiry, issuer, audience). - The token's
custom:approvedclaim must be the exact string"true". If you're signed up but not yet approved by an admin, you get the same401an invalid token would produce — there's no separate "authenticated but pending" status code.
Approval is baked into your JWT at the moment it's issued, not read fresh on every request. If an admin approves you while you're still signed in, your existing token keeps failing until you get a new one (sign in again, or let your refresh token rotate). This is exactly what the "Awaiting Approval" screen's polling + forced-relogin dance is working around — see Authentication, Signup & Approval.
Sign up
POST /api/auth/signupThis endpoint only covers email/password signup. Google sign-in never calls it — the Cognito Hosted UI collects Google credentials directly, outside this API.
Body
{
"email": "new@example.com",
"password": "Str0ngPass!",
"name": "New User",
"recaptcha_token": "<reCAPTCHA Enterprise client token>"
}Response 200
{ "message": "Account created. Check your email to verify." }| Status | Meaning |
|---|---|
200 | Cognito account created — check email for the verification link |
400 | email / password / name missing |
400 | recaptcha_token missing, or its score is below the 0.5 threshold |
400 | Password doesn't meet Cognito's policy |
409 | An account with this email already exists |
Creating the account here is not the end of the story — you still need to confirm your email and get admin-approved before you can call anything else. See the full lifecycle.
Callback
POST /api/auth/callbackExchanges an OAuth authorization code for Cognito tokens, and this is fully
implemented server-side (including setting an HttpOnly session cookie) — but the
shipped frontend doesn't call it. The login page exchanges the code with
Cognito's token endpoint directly from the browser instead, and keeps tokens in
localStorage. See Authentication, Signup &
Approval for the full picture. Documented here for
completeness/accuracy, not as the flow you should build against.
Body
{
"code": "<authorization code from the Cognito redirect>",
"redirect_uri": "https://app.example.com/login/",
"code_verifier": "<PKCE verifier, optional>"
}Response 200
{
"access_token": "string",
"id_token": "string",
"expires_in": 3600,
"token_type": "Bearer"
}Also sets Set-Cookie: lkwiz_token=<id_token>; HttpOnly[; Secure]; SameSite=Strict; Path=/; Max-Age=<expires_in>
(Secure is dropped only when STAGE=dev).
| Status | Meaning |
|---|---|
200 | Tokens issued |
400 | code missing |
400 | redirect_uri missing |
No refresh_token in the response body, despite what specs/SPECS.md §5.1 says — the
shipped code only returns access_token / id_token / expires_in / token_type.
Me
GET /api/auth/meResponse 200
{
"user_id": "user-abc123",
"email": "alice@example.com",
"name": "Alice",
"role": "member",
"status": "approved",
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-01-12T14:30:00+00:00"
}status is "approved" or "pending" — read live from DynamoDB, not from your JWT's
custom:approved claim. This is the one place in the API where approval status is
always fresh, which is exactly why the "Awaiting Approval" page polls this endpoint
rather than just checking the token it already has.
| Status | Meaning |
|---|---|
200 | OK |
401 | No sub in your token (not reachable in practice — the shared authorizer already requires a valid, approved token to get this far) |
404 | No profile record exists for your user id |
There's no "rejected" value here. A rejected user can't reach this endpoint anyway —
rejection disables the Cognito account outright — but if you're building against this
contract, don't expect a third status value the way GET /api/admin/users has one.
WS Ticket
POST /api/ws/ticketMints a short-lived credential for the real-time collaboration WebSocket, since a
$connect handshake can't carry a normal Authorization header.
Response 200
{ "ticket": "3f9e2c10-...-uuid4" }| Status | Meaning |
|---|---|
200 | Ticket issued, valid for 8 hours |
401 | No sub in your token (same practical non-reachability as Me) |
The ticket snapshots your role and approved status from your current JWT, at
mint time — it does not re-check either against the database when the WebSocket
connects. It also isn't deleted after first use, so despite the name it's valid for
repeated connects until it expires. Mint a fresh one per session; don't try to reuse
or cache it beyond that.
See also
- Authentication, Signup & Approval — the account lifecycle, the approval gate, and known gaps between this API and the shipped frontend.
specs/api/auth.md— the authoritative spec, including full validation order and every Cognito trigger this domain depends on.