readOnly and writeOnly in OpenAPI: one schema for create, response, and password fields
Most teams model the same resource three times: a User for responses, a CreateUser for requests, and a UpdateUser for patches. The copies drift within a sprint. The response forgets a field the request requires; the create DTO starts accepting id; someone adds password_hash to the response "temporarily." The duplication is the bug. OpenAPI already lets one schema describe both directions with two annotations: readOnly and writeOnly.
The two annotations are directional
| Annotation | Sent in request? | Returned in response? | Typical fields |
|---|---|---|---|
readOnly: true | Ignored or rejected | Yes | id, created_at, etag, computed totals |
writeOnly: true | Yes | Never | password, current_password, tokens, card secrets |
| neither | Yes | Yes | Normal mutable fields like display_name |
readOnly means the client may read but never writes the value. writeOnly is the mirror: the client may send it, but it is never serialized back. They are not hints; codegen and validators treat them as part of the contract.
One User schema, both directions
User:
type: object
required: [id, email, created_at]
properties:
id:
type: string
format: uuid
readOnly: true
email:
type: string
format: email
display_name:
type: string
created_at:
type: string
format: date-time
readOnly: true
updated_at:
type: string
format: date-time
readOnly: true
password:
type: string
format: password
minLength: 12
writeOnly: true
description: Required on create. Ignored on profile updates; use the password endpoint to change it.The same User schema now describes what GET /users/{id} returns and what POST /users accepts. A generator that understands the annotations splits it for you. The response type includes id and timestamps and excludes password; the request type includes password and excludes the server-managed fields:
// Response type (what GET returns)
export interface User {
id: string;
email: string;
display_name?: string;
created_at: string;
updated_at?: string;
}
// Request type (what POST accepts) — password present, id/timestamps absent
export type UserCreate = Omit<
User, "id" | "created_at" | "updated_at"> & { password: string };Not every generator emits the split automatically; older ones produce one interface and document the annotations in comments. If yours does not split, define thin request/response schemas with allOf and $ref so you still define each field once (see below).
writeOnly is a security control
writeOnly is the correct home for every secret a client sends once: passwords, password-confirmation fields, the current password on a change endpoint, API key seeds, and raw card tokens. Marking them writeOnly does three things:
- They never appear in a response schema, so generated response types cannot leak them.
- Documentation renderers hide them from response examples.
- A spec-driven scanner or response validator can flag an endpoint that actually returns a field declared
writeOnly.
Annotations do not replace server-side discipline, you still must not log or persist secrets in reversible form, but they make the contract express the intent and let tooling catch regressions. Pair format: password with writeOnly: true; the format affects rendering and validation hints, while writeOnly controls direction.
The required-field rule
A readOnly property that is required is required only in responses. A writeOnly property that is required is required only in requests. Generators and validators that honor the annotations apply this automatically, which is how id can be required on the way out without being sent on the way in.
This matters for create versus update. The password is required to register but absent on a profile edit. Express the two request shapes by composing the shared schema rather than copying fields:
UserCreate:
type: object
required: [email, password]
allOf:
- $ref: '#/components/schemas/User'
UserUpdate:
type: object
description: Profile update. Send only fields to change; password is not accepted here.
allOf:
- $ref: '#/components/schemas/User'UserCreate adds password and email to the required set; UserUpdate leaves them optional and points password changes to a dedicated endpoint. Both reuse the single User definition, so adding a field to the resource updates every direction in one place.
PATCH and writeOnly fields
Partial updates interact badly with secrets. If PATCH /users/{id} accepts the same schema as create, clients cannot tell whether omitting password means "leave it unchanged" or "clear it," and a generated form may demand a password on every edit. Keep secret changes on a dedicated route with an explicit shape:
paths:
/users/{id}/password:
put:
summary: Change the current user's password
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [current_password, new_password]
additionalProperties: false
properties:
current_password: { type: string, writeOnly: true }
new_password: { type: string, format: password, minLength: 12, writeOnly: true }
responses:
'204': { description: Password changed }
'401': { description: current_password is incorrect }This removes the ambiguity entirely: the profile PATCH never carries a password, and the password endpoint always carries both the old and the new.
What scanners and AI callers get wrong
When a spec is reconstructed from code, a naive parser reads every field on a model and puts it in both the request and response, which is how internal fields like password_hash, role, and internal flags leak into the create contract, and how server-generated id ends up marked as a required input. A deterministic scanner should trace which fields the handler actually reads versus serializes, mark the rest readOnly or writeOnly, and report a gap when the framework cannot prove the direction (for example a model reused blindly for both binding and response).
An AI agent or generated client that respects the annotations will not send id or created_at on a POST and will not expect password in a GET response. That removes a whole class of pointless fields from agent-generated requests.
Checklist
- Mark every server-generated field
readOnlyand every secret-input fieldwriteOnly; leave genuinely mutable fields unannotated. - Use one core schema per resource and compose create/update variants with
allOfand$refinstead of copying fields. - Remember required
readOnlyapplies to responses and requiredwriteOnlyapplies to requests. - Pair secret fields with
format: passwordand move password changes to a dedicated endpoint so PATCH never carries them. - Never return a
writeOnlyfield; use response validation or a scanner rule to enforce it. - Generate the client and confirm request types exclude
id/timestamps and response types exclude secrets. - When scanning code, derive direction from what the handler reads and serializes, and mark unknown direction as a gap.
Get these right and a single schema stays the source of truth for every direction, secrets stay out of responses, and generated create and update types are correct without three copies to maintain.
You can annotate one schema for both directions, generate split request and response types, and verify secrets never appear in a mock response in one local-first workspace, right in your browser. For the partial-update semantics that make the dedicated password endpoint necessary, see PUT vs JSON Merge Patch vs JSON Patch.