# 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

``` yaml
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:

``` ts
// 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:

1.  They never appear in a response schema, so generated response types cannot leak them.
2.  Documentation renderers hide them from response examples.
3.  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:

``` yaml
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:

``` yaml
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

1.  Mark every server-generated field `readOnly` and every secret-input field `writeOnly`; leave genuinely mutable fields unannotated.
2.  Use one core schema per resource and compose create/update variants with `allOf` and `$ref` instead of copying fields.
3.  Remember required `readOnly` applies to responses and required `writeOnly` applies to requests.
4.  Pair secret fields with `format: password` and move password changes to a dedicated endpoint so PATCH never carries them.
5.  Never return a `writeOnly` field; use response validation or a scanner rule to enforce it.
6.  Generate the client and confirm request types exclude `id`/timestamps and response types exclude secrets.
7.  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](https://www.powerduck.com/app/?ref=powerduck.com). For the partial-update semantics that make the dedicated password endpoint necessary, see [PUT vs JSON Merge Patch vs JSON Patch](https://www.powerduck.com/blog/openapi-patch-json-merge-patch-rfc6902-vs-put/).

