Skip to main content

Authentication

Authentication surface in Crewship is split across several flows. The interactive web flow is /api/auth/* (NextAuth-compatible — see Internal API); everything covered on this page is the /api/v1/auth/* surface that wraps email/password signup, Google sign-in, CLI pairing, active session listing/revocation, long-lived CLI tokens, and password recovery. All /api/v1/auth/* routes are mounted behind a rate limit of 10 req/min/IP. Routes marked auth required read the caller’s identity from the session cookie issued by /api/auth/callback/credentials; the rest are intentionally unauthenticated because the request body (a state token, pairing code, or reset token) IS the credential.

Endpoints


NextAuth-compatible browser session

The interactive web flow lives under /api/auth/* (no /v1). These are the NextAuth-compatible routes the browser uses for the cookie-based session; the full table is in the API overview. Two of them are worth calling out here:

POST /api/auth/token/refresh

Rotates the session token. The browser calls this when its short-lived access token nears expiry to obtain a fresh one without re-entering credentials. Auth: the refresh-token cookie IS the credential (no Authorization header). Behaviour: layered CSRF defence (POST-only, path-scoped SameSite=Lax refresh cookie, and a same-origin Origin/Referer check). Implements refresh-token rotation with reuse detection — each refresh token carries a unique JTI, the session row tracks the current JTI, and a successful refresh CAS-rotates old → new. A request replaying an already-rotated JTI is treated as token theft: the entire session is revoked and the call returns 401. On any failure both auth cookies are cleared so a dead token isn’t resent.

GET /api/auth/error

NextAuth error redirect target. Echoes the ?error= query value back as JSON (defaulting to Default when absent) so the login UI can render a human-readable message. Auth: none. Response: 200 OK

Email/password signup

POST /api/v1/auth/signup

Create a new user, their starter workspace, and the OWNER workspace membership in a single transaction. Off by default — enable with CREWSHIP_ALLOW_SIGNUP=true at startup. On a closed instance (default), the endpoint returns 403 with the env-var name in the error message.
Contract change (2026-07): this endpoint no longer answers 409 Email already registered, no longer returns the new account’s id, and no longer sets a session cookie. All three told an unauthenticated caller whether an address has an account here, which made signup an email-enumeration oracle. A brand-new address and one that already exists now get byte-identical responses. Clients must send the user to /login afterwards instead of assuming a session.
Request body:
Response: 202 Accepted, with the same body whether the account was created or the address already had one:
No session cookie is set — sign in at POST /api/auth/callback/credentials (or the /login page) once the account exists. When the address was already taken, nothing is written and the owner gets an “someone tried to sign up with your email” notice, if a mailer is configured (RESEND_API_KEY); otherwise the attempt is only logged, with the address hashed.
Signup returns no account id, and there is deliberately no endpoint that maps an arbitrary email to one — that would re-open this same oracle behind an OWNER/ADMIN gate. To put someone in your workspace when the address is all you have, create a workspace invitation first: signup redeems every live invitation for the address inside the same transaction that creates the account, so the new user lands in the inviting workspace at the invited role. POST /api/v1/workspaces/{workspaceId}/members still takes a user_id and nothing else.
Signup is distinct from POST /api/v1/bootstrap — bootstrap is the one-shot empty-instance flow that always creates the first OWNER regardless of CREWSHIP_ALLOW_SIGNUP. Signup is the steady-state “add another user to this open instance” surface. Most production deployments leave signup off and onboard via device pairing or direct admin invites.

Google sign-in

Three endpoints implement the OAuth2 redirect dance. They are wired only when GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set at startup; otherwise /redirect and /callback are not registered at all and /status reports enabled: false.

GET /api/v1/auth/google/status

Probe that tells the login page whether to render the “Continue with Google” button. Auth: none. Response: 200 OK

GET /api/v1/auth/google/redirect

Starts the OAuth2 flow. The handler mints a single-use state token (32 random bytes, stored in oauth_states with the requested post-login redirect, 15-min TTL) and 307s the browser to accounts.google.com. Auth: none. Query parameters: Response: 307 Temporary Redirect to the Google authorization URL. Errors land as 404 (“Google sign-in not configured”) when OAuth is disabled or 500 on a state-insert failure.

GET /api/v1/auth/google/callback

Google’s redirect target. Atomically consumes the state token (single-use via DELETE ... RETURNING), exchanges the auth code for an access token, fetches the user’s profile from https://www.googleapis.com/oauth2/v3/userinfo, upserts the row in users + accounts, and mints a fresh user_sessions row plus access/refresh cookies. Then 307s the browser to the validated redirect_uri captured during /redirect. Auth: none. Query parameters:

Device pairing

A device-code flow (RFC 8628 in spirit) that hands a Crewship session to a locally-installed CLI without copy-pasting a long token. The UI calls /pair/start to mint a human-typeable code, polls /pair/poll for status, and the CLI on the operator’s machine exchanges the same code at /pair/redeem for a long-lived CLI token. Codes are 8 chars of Crockford-ish base32 (no 0/O/1/I/L) formatted as XXXX-XXXX, 10-minute TTL.

POST /api/v1/auth/pair/start

Mint a new pairing code for the authenticated user. Auth: required. Request body (optional):
Response: 200 OK

GET /api/v1/auth/pair/poll

UI polls this every ~2s while showing the code to the user. The row is reported as expired when (a) the code doesn’t exist, (b) it belongs to a different user, or (c) the TTL has passed — the three cases are indistinguishable on purpose so /poll cannot enumerate other users’ codes. Auth: required. Query parameters: Response: 200 OK

POST /api/v1/auth/pair/redeem

The CLI calls this from the operator’s machine to exchange the code for a fresh cli_tokens row. Unauthenticated by design — the code IS the credential. Single-use (the UPDATE filters on status='pending' so two concurrent redeems can’t both win), 10-min TTL, and the route inherits the 10 req/min/IP cap from the auth-tier rate limiter. Auth: none. Request body:
Response: 200 OK

Active session management

The Settings → Sessions UI uses these endpoints to show the user every device currently signed in, with a “revoke” button per row. Revoking the caller’s own session is allowed; the frontend handles the resulting 401 by hard-redirecting to /login.

GET /api/v1/auth/sessions

Lists the caller’s active (non-revoked) sessions, newest-active first. Auth: required. Response: 200 OK

POST /api/v1/auth/sessions/{id}/revoke

Flips revoked_at on a single session owned by the caller. The 404 path covers both “doesn’t exist” and “doesn’t belong to you” so callers can’t enumerate other users’ session ids by guessing. Auth: required. Path parameters: Response: 200 OK

CLI tokens

Long-lived bearer tokens used by crewship CLI invocations and CI agents. Tokens are minted once, only the SHA-256 hash is stored, and the raw value is returned exactly once in the create response — the rest of the surface returns metadata only. Token format: crewship_cli_ + 64 hex chars (32 random bytes, 256-bit entropy). ADMIN-tier tokens carry the crewship_admin_ prefix instead. IsCLIToken is the helper the auth middleware uses to detect either prefix before falling through to JWE validation.

POST /api/v1/auth/cli-token

Mints a new token for the calling user. Auth: required. Request body (optional):
Response: 200 OK
The token field is the only time the raw value leaves the server. Store it immediately — there is no recovery path. Lost tokens must be revoked and replaced.

GET /api/v1/auth/cli-token/validate

Confirms the current request’s CLI token (the Authorization: Bearer ... header) is valid. Used by crewship login --status and the CLI’s startup self-check. Auth: required (call this with the token you want to verify). Response: 200 OK
A revoked or unknown token never reaches this handler — the auth middleware rejects with 401 before dispatch.

GET /api/v1/auth/cli-tokens

Lists all CLI tokens belonging to the calling user, newest first. The plaintext is never returned. Auth: required. Response: 200 OK

DELETE /api/v1/auth/cli-tokens/{tokenId}

Marks a token revoked. Subsequent uses are rejected by the auth middleware (ValidateCLIToken returns “CLI token revoked”). Auth: required. Path parameters: Response: 200 OK

User profile (self-service)

Every authenticated user can edit their own identity and rotate their own password without any workspace role. Both routes read the caller from the session (or CLI token) — there is no user id in the path.

PATCH /api/v1/users/me

Update your own profile. Auth required. Currently only full_name is editable. Email changes require a re-verification flow and are intentionally out of scope; an email field in the body is ignored. Request body: Response: 200 OK{ "id", "email", "full_name", "avatar_url" }.

POST /api/v1/users/me/password

Change your own password. Auth required. Verifies the current password, stores a fresh bcrypt hash (cost 12), then revokes every other active session for you (reason password_change) — the session you called this from stays signed in. CLI-token callers have no browser session to preserve, so all browser sessions are revoked. Request body: Response: 200 OK{ "success": true, "sessions_revoked": <n> }.
This route is treated as an auth endpoint even though its URL isn’t one. Checking current_password answers differently for a right and a wrong guess, so a stolen session could otherwise be spent recovering the password itself — which buys persistence past session revocation, and whatever else the person reused it on. Two controls, both shared with sign-in:
  • it rides the strict per-IP bucket (/api/v1/auth/*, /api/v1/bootstrap) rather than the general API bucket, and unlike the general bucket that one does not exempt authenticated CLI tokens;
  • wrong guesses advance the same per-account lockout counter as sign-in (users.failed_login_count), so once they cross login.lockout_threshold the account freezes for login.lockout_duration_sec and this endpoint returns 423 — even for the correct password. A successful change clears the counter. Both values are tunable (crewship admin ratelimits); an operator can unlock an account with crewship admin reset-password.
Drive it from the CLI with crewship auth passwd.

POST /api/v1/users/me/avatar

Upload (or replace) your own avatar. Auth required. multipart/form-data with a single file field. Request: multipart body with required file; response: profile object.
  • Max 2 MB; the content type is sniffed from the bytes and must be PNG, JPEG, or WebP (a mismatched or unsupported type is a 400).
  • The image header is decoded (image.DecodeConfig) to reject a file that isn’t a real, parseable image, and each side is capped at 4096 px (decompression-bomb defense) — a corrupt or over-large image is a 400.
  • EXIF/metadata is preserved. The bytes are stored verbatim, so any EXIF (incl. GPS) in a JPEG survives. This is an accepted trade-off for a self-uploaded avatar; stripping would require a full re-encode (and webp re-encode isn’t in the Go stdlib). Strip client-side first if that matters.
  • Stored server-side; avatar_url is set to the authenticated serve endpoint below (with a ?v= cache-buster so a replacement refreshes).
Response: 200 OK — the profile object { "id", "email", "full_name", "avatar_url" }.

DELETE /api/v1/users/me/avatar

Clear your avatar back to initials. Auth required. Removes the stored file (if any) and sets avatar_url to null. Response: 200 OK — the profile object. Request: no body. Status: 200 success; 401 not authenticated; 500 storage/delete failure.

GET /api/v1/users/{id}/avatar

Stream a user’s avatar bytes with the correct Content-Type. Auth required — any signed-in user may fetch a member’s avatar (rosters render each other); an unauthenticated request is 401. 404 when the user has no uploaded avatar. This is the URL avatar_url points at after an upload. Request: no body. Response: raw avatar bytes. Status: 200 success; 401 unauthenticated; 404 no avatar. Drive avatars from the CLI with crewship auth avatar.

Password recovery

Email-based recovery for users who don’t have shell access to the box. Admins should prefer crewship admin reset-password — it writes directly to the DB and doesn’t require a mailer. The mailer transport is wired at startup from RESEND_API_KEY + RESEND_FROM. The reset link is built from CREWSHIP_PUBLIC_URL (a validated http(s)://host value parsed once at boot). When the public URL is unset or malformed, /forgot silently no-ops — without it there’s no safe way to build a link that isn’t Host-header-injection-controllable.

POST /api/v1/auth/forgot

Issues a single-use, 30-min reset token if the email matches a user and a mailer transport is configured. Always returns 200 with the same JSON body regardless — the endpoint cannot be used to enumerate accounts. Auth: none. Request body:
Response: 200 OK
The response body is byte-for-byte identical for “no such email”, “mailer disabled”, “public URL unset”, and a successful send — the only signal a successful send happened is the user receiving the email.

POST /api/v1/auth/reset

Consumes the reset token and sets a new password. The token row is burned inside a transaction (race-protected: two concurrent calls with the same token serialise on the DELETE, only one wins). On success, every active session for the user is revoked so a stolen cookie can’t outlive the reset. Auth: none — the token IS the credential. Request body:
Response: 200 OK
Password reset is one of the few endpoints where a 500 with a non-generic message is intentional: if the password write commits but session revocation fails, leaving the user with valid old cookies would silently undo the security purpose of the reset. The 500 forces an admin-visible log entry.

See also

  • Internal API — the NextAuth-compatible /api/auth/* surface used by the browser session cookie flow.
  • Security overview — how the session token feeds into RBAC.
  • crewship login — CLI commands that consume /pair/start + /pair/redeem and store the resulting token in ~/.crewship/cli-config.yaml.