People and sessions
Sign-up, sign-in, sessions, password resets and email verification.
packages/api/src/auth/auth.controller.ts by packages/docs/tools/generate-reference.mjs. Every example is a real exchange recorded against the control plane by packages/api/src/testing/reference-examples.spec.ts, with ids from that run. Credentials in the formats this product issues (kr_, krsk_, krses_, JWTs) and values under credential-named fields (password, *_secret, *_token, *_key, lookup_hash) are elided at record time; the recorded file and every page of this site are swept for credential shapes (kr_, krsk_, krses_, JWTs, URLs carrying a password, password fields) before either is accepted. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.The person plane's front door.
Three endpoints here are unauthenticated by definition — sign up, log in, ask for a reset — and all three answer the same thing whether or not the address has an account. That is not politeness: an API that says "no such user" is a list of a vendor's staff to anyone who can guess addresses, and this product's customers are companies whose staff directory is public.
The API sets no cookie and reads none. A session is a bearer token, so there is no ambient authority attached to a cross-site request and therefore no CSRF surface at all; a browser front end keeps the token in its own server-side session and never hands it to script. That is a deliberate division of labour rather than an omission, and it is why the dashboard is a separate deliverable rather than a folder in this package.
POST /v1/auth/users
202, always, with no body that varies. A new account is created and
queued a verification link; an address that already has one is told so in
its own inbox and the caller is told nothing. Both branches queue exactly
one message, so the queue depth is not an oracle either.
Answers 202 on success.
Authentication.
- No credential.
Body. Validated by this schema, from the control plane's own source:
const SignUp = z.object({
email: Email,
password: Password,
name: Name.optional(),
});const Email = z.email().max(320);const Password = z
.string()
.min(PASSWORD_POLICY.minLength)
.max(PASSWORD_POLICY.maxLength);Example.
curl https://keyring-api.belghalem.fr/v1/auth/users \
-X POST \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"password": "<password>",
"name": "Ada"
}'HTTP/1.1 202 Accepted
Content-Type: application/json
{
"object": "signup",
"email": "ada@example.com",
"accepted": true
}POST /v1/auth/sessions
Answers 201 on success.
Authentication.
- No credential.
Body. Validated by this schema, from the control plane's own source:
const LogIn = z.object({
email: Email,
password: z.string().min(1).max(PASSWORD_POLICY.maxLength),
workspace_id: z.uuid().optional(),
});const Email = z.email().max(320);Example.
curl https://keyring-api.belghalem.fr/v1/auth/sessions \
-X POST \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"password": "<password>"
}'HTTP/1.1 201 Created
Content-Type: application/json
{
"object": "session",
"token": "krses_…",
"id": "01a0ade7-b8c5-7431-bf2c-f645f39be742",
"display_prefix": "krses_…",
"active_workspace_id": null,
"created_at": "2026-09-17T05:47:19.620Z",
"last_seen_at": "2026-09-17T05:47:19.620Z",
"expires_at": "2026-09-17T17:47:19.620Z",
"absolute_expires_at": "2026-10-17T05:47:19.620Z",
"user": {
"id": "01a0ade7-b878-7739-871a-8faaa66097ed",
"email": "ada@example.com",
"email_verified": false,
"name": "Ada",
"created_at": "2026-09-17T05:47:19.543Z",
"last_login_at": null
},
"memberships": []
}GET /v1/auth/me
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Example.
curl https://keyring-api.belghalem.fr/v1/auth/me \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "me",
"user": {
"id": "01a0ade7-b878-7739-871a-8faaa66097ed",
"email": "ada@example.com",
"email_verified": true
},
"active_workspace_id": null,
"memberships": []
}Example: With a workspace selected.
curl https://keyring-api.belghalem.fr/v1/auth/me \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "me",
"user": {
"id": "01a0ade7-b878-7739-871a-8faaa66097ed",
"email": "ada@example.com",
"email_verified": true
},
"active_workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
"memberships": [
{
"workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
"role": "owner",
"name": "Acme Payments",
"slug": "acme-payments"
}
]
}PATCH /v1/auth/me
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Body. Validated by this schema, from the control plane's own source:
const UpdateMe = z.object({ name: Name.nullable() });Example.
curl https://keyring-api.belghalem.fr/v1/auth/me \
-X PATCH \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Ada Lovelace"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "user",
"id": "01a0ade7-b878-7739-871a-8faaa66097ed",
"email": "ada@example.com",
"email_verified": true,
"name": "Ada Lovelace",
"created_at": "2026-09-17T05:47:19.543Z",
"last_login_at": "2026-09-17T05:47:19.615Z"
}POST /v1/auth/me/password
Changing a password ends every other session. The reason someone changes one is usually that they think somebody else has it, and leaving that other session alive answers the wrong question.
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Body. Validated by this schema, from the control plane's own source:
const ChangePassword = z.object({
current_password: z.string().min(1).max(PASSWORD_POLICY.maxLength),
password: Password,
});const Password = z
.string()
.min(PASSWORD_POLICY.minLength)
.max(PASSWORD_POLICY.maxLength);Example.
curl https://keyring-api.belghalem.fr/v1/auth/me/password \
-X POST \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"current_password": "<password>",
"password": "<password>"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "password",
"changed": true
}GET /v1/auth/sessions
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Example.
curl https://keyring-api.belghalem.fr/v1/auth/sessions \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "list",
"data": [
{
"id": "01a0ade7-bd2f-7003-a9e0-cb23c464439c",
"display_prefix": "krses_…",
"active_workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
"created_at": "2026-09-17T05:47:20.751Z",
"last_seen_at": "2026-09-17T05:47:20.751Z",
"expires_at": "2026-09-17T17:47:20.751Z",
"absolute_expires_at": "2026-10-17T05:47:20.751Z",
"current": false
},
{
"id": "01a0ade7-bc62-72fd-a507-a8ff3372a804",
"display_prefix": "krses_…",
"active_workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
"created_at": "2026-09-17T05:47:20.546Z",
"last_seen_at": "2026-09-17T05:47:20.546Z",
"expires_at": "2026-09-17T17:47:20.546Z",
"absolute_expires_at": "2026-10-17T05:47:20.546Z",
"current": true
}
]
}DELETE /v1/auth/sessions/current
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Example.
curl https://keyring-api.belghalem.fr/v1/auth/sessions/current \
-X DELETE \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "session",
"id": "01a0ade7-bc62-72fd-a507-a8ff3372a804",
"revoked": true
}DELETE /v1/auth/sessions/:id
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Path parameters. id.
Example.
curl https://keyring-api.belghalem.fr/v1/auth/sessions/01a0ade7-bd2f-7003-a9e0-cb23c464439c \
-X DELETE \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "session",
"id": "01a0ade7-bd2f-7003-a9e0-cb23c464439c",
"revoked": true
}POST /v1/auth/sessions/current/workspace
Which workspace this session acts in. It rewrites the session row rather than issuing a token: the token identifies the sign-in, and minting one per workspace would mean a person in three workspaces holds three live credentials for one sign-in — three things to steal and three to revoke.
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Body. Validated by this schema, from the control plane's own source:
const SwitchWorkspace = z.object({ workspace_id: z.uuid() });Example.
curl https://keyring-api.belghalem.fr/v1/auth/sessions/current/workspace \
-X POST \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "session",
"id": "01a0ade7-b8c5-7431-bf2c-f645f39be742",
"active_workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7"
}POST /v1/auth/email_verifications
A second verification link. Session-authenticated, 202 always: the caller
is already this account, and an address that is already verified queues
nothing.
Answers 202 on success.
Authentication.
- A
krses_dashboard session only.
Example.
curl https://keyring-api.belghalem.fr/v1/auth/email_verifications \
-X POST \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'HTTP/1.1 202 Accepted
Content-Type: application/json
{
"object": "email_verification",
"accepted": true
}POST /v1/auth/email_verifications/consume
Unauthenticated on purpose: the link arrives in a mailbox and is clicked from wherever that mailbox is read, which is not necessarily the browser holding the session. The token is the whole credential and it verifies one address on one account.
Answers 200 on success.
Authentication.
- No credential.
Body. Validated by this schema, from the control plane's own source:
const ConsumeVerification = z.object({ token: z.string().min(1).max(500) });Example.
curl https://keyring-api.belghalem.fr/v1/auth/email_verifications/consume \
-X POST \
-H "Content-Type: application/json" \
-d '{
"token": "…"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "email_verification",
"verified": true
}POST /v1/auth/password_resets
Answers 202 on success.
Authentication.
- No credential.
Body. Validated by this schema, from the control plane's own source:
const RequestReset = z.object({ email: Email });const Email = z.email().max(320);Example.
curl https://keyring-api.belghalem.fr/v1/auth/password_resets \
-X POST \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com"
}'HTTP/1.1 202 Accepted
Content-Type: application/json
{
"object": "password_reset",
"accepted": true
}POST /v1/auth/password_resets/consume
Answers 200 on success.
Authentication.
- No credential.
Body. Validated by this schema, from the control plane's own source:
const ConsumeReset = z.object({
token: z.string().min(1).max(500),
password: Password,
});const Password = z
.string()
.min(PASSWORD_POLICY.minLength)
.max(PASSWORD_POLICY.maxLength);Example.
curl https://keyring-api.belghalem.fr/v1/auth/password_resets/consume \
-X POST \
-H "Content-Type: application/json" \
-d '{
"token": "…",
"password": "<password>"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "password_reset",
"consumed": true
}Shared validators
Defined once in packages/api/src/validation.ts and used by the schemas above.
const Name = z.string().min(1).max(200);