Skip to main content

POST /subjects

Requires manage-users. Creates a type: "user" subject: the subject itself, its users row and the tenant's default_for_new_subjects roles — the same composition self-registration performs (one shared function, so the two doors cannot drift on what "a new subject" means). There is no password field, and that is a rule, not an omission: creating a subject through this door writes an update-password required action instead, so no operator ever handles a user's password, and the created subject cannot complete a login until an out-of-band channel sets one. A body carrying password is refused with 400 before the usecase ever runs — Zod's generated schema already sets additionalProperties: false.

curl -sS -D - -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username": "ada", "email": "ada@example.com"}' \
http://localhost:3080/admin/tenants/demo/subjects
HTTP/1.1 201 Created
x-request-id: 01a0ea54-250f-791a-a610-c67bd0c2f3b0
cache-control: no-store
etag: "6d421703daec2dfafa43124a92e862b052fde60549b94a1a3b22285a6d02b126"
content-type: application/json; charset=utf-8
content-length: 157
Date: Mon, 28 Sep 2026 23:22:58 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"id":"01a0ea54-2519-7513-8bf7-c105e0cfd9dd","type":"user","username":"ada","email":"ada@example.com","enabled":true,"created_at":"2026-09-28T23:22:58.200Z"}

In its demo; the etag is the one GET /subjects/:id answers for the new subject.

A body carrying password and a username already in use, in that order. The first refusal is the generated schema's, so its title is the framework's generic one rather than a Bad Request the usecase chose — that is what "refused before the usecase ever runs" looks like from outside:

curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username": "ada", "password": "hunter2"}' \
http://localhost:3080/admin/tenants/demo/subjects
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username": "ada"}' \
http://localhost:3080/admin/tenants/demo/subjects
{"type":"about:blank","title":"Error","status":400,"detail":"body must NOT have additional properties: password","errors":[{"path":"password","message":"must NOT have additional properties"}],"instance":"01a0ea54-2528-7865-9797-ddba85f9256d"}
{"type":"about:blank","title":"Conflict","status":409,"detail":"the username \"ada\" is already in use","instance":"01a0ea54-2532-7ded-a4a1-78972123db93"}

A username holding a bidirectional control character (U+202A to U+202E or U+2066 to U+2069) is refused, and the field error carries the code bidi_control_refused. The rule is the same, with the same message and code, for what a person reads on a page or in a list: a profile name (name, given_name, family_name, middle_name, nickname, preferred_username), a tenant's display_name (create, import, PATCH and settings), a client's client_id, name, client_name and description, a role's or group's name and description, and a scope's description and consent_text; inside an import document each is reported at its path. LRM, RLM and ALM, which Persian and Arabic text uses, and plain Arabic or Hebrew, are accepted. A write that changes a value is refused whenever the new value holds a control; the stored value may be kept byte-identical, so it can be sent back unchanged with an unrelated edit, and it is never rewritten. Editing a legacy value while leaving its control in place is refused: take the control out, or leave the field alone. A creation is checked by the generated schema, an amendment against the stored value by the usecase. These two answers came from the integration suite's own app not from a compose stack, because minting an admin token takes the loopback flow above; POST a username ending in U+202E, then PATCH a profile's given_name to a value ending in U+2066:

{"type":"about:blank","title":"Error","status":400,"detail":"username: must not contain bidirectional control characters (U+202A to U+202E, U+2066 to U+2069)","errors":[{"path":"username","message":"must not contain bidirectional control characters (U+202A to U+202E, U+2066 to U+2069)","code":"bidi_control_refused"}],"instance":"01a152f1-da3b-718b-88d7-1074c1ec32aa"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"given_name: must not contain bidirectional control characters (U+202A to U+202E, U+2066 to U+2069)","errors":[{"path":"given_name","message":"must not contain bidirectional control characters (U+202A to U+202E, U+2066 to U+2069)","code":"bidi_control_refused"}],"instance":"01a152f1-da58-7bab-86fe-2e481040445e"}