diff --git a/reference/database/schema.md b/reference/database/schema.md
index 5b342f06..d12e32e1 100644
--- a/reference/database/schema.md
+++ b/reference/database/schema.md
@@ -73,14 +73,15 @@ type MyTable @table {
Optional arguments:
-| Argument | Type | Default | Description |
-| -------------- | --------- | ----------------------------- | --------------------------------------------------------------------------- |
-| `table` | `String` | type name | Override the table name |
-| `database` | `String` | `"data"` | Database to place the table in |
-| `expiration` | `Int` | — | Seconds until a record goes stale (useful for caching tables) |
-| `eviction` | `Int` | `0` | Additional seconds after `expiration` before a record is physically removed |
-| `scanInterval` | `Int` | `(expiration + eviction) / 4` | Seconds between eviction scans |
-| `replicate` | `Boolean` | true | Enable replication of this table |
+| Argument | Type | Default | Description |
+| -------------- | --------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
+| `table` | `String` | type name | Override the table name |
+| `database` | `String` | `"data"` | Database to place the table in |
+| `expiration` | `Int` | — | Seconds until a record goes stale (useful for caching tables) |
+| `eviction` | `Int` | `0` | Additional seconds after `expiration` before a record is physically removed |
+| `scanInterval` | `Int` | `(expiration + eviction) / 4` | Seconds between eviction scans |
+| `replicate` | `Boolean` | true | Enable replication of this table |
+| `cacheControl` | `String` | — | `Cache-Control` header value emitted on anonymous GET/HEAD 200/304 responses for this table |
**`expiration`, `eviction`, and `scanInterval`**
@@ -162,6 +163,37 @@ type Event @table(database: "analytics", expiration: 86400) {
**Replication:** Replication is enabled by default for all tables. Note that if you disable replication on a table and re-enable it later, it will not catch-up on previous writes during when the replication was disabled.
+#### `cacheControl`
+
+
+
+The `cacheControl` argument sets a `Cache-Control` header value emitted on anonymous (unauthenticated) GET/HEAD `200`/`304` responses for this table. It is designed for tables whose content is public and safe for shared caches (CDNs, reverse proxies) to store.
+
+```graphql
+type Product @table(cacheControl: "public, max-age=60") @export {
+ id: Long @primaryKey
+ name: String
+ price: Float
+}
+```
+
+Key semantics:
+
+- **Anonymous reads only.** The header is emitted only when the request carries no authenticated principal. Authenticated responses instead receive an identity floor of `Cache-Control: private, no-cache` (plus `Vary: Authorization`/`Cookie`) — the table declaration is not inherited by authenticated reads.
+- **Explicit opt-in required.** Anonymous readability alone does not cause Harper to emit shared-cache headers, because a table gated on request attributes (IP, headers, etc.) could leak across a URL-keyed cache. The `cacheControl` declaration is the explicit statement that the content is safe to cache publicly.
+- **Never on 401 responses.** A rejected request always gets `Cache-Control: private, no-cache` regardless of any declaration.
+- **Visible in `describe_table`.** The value is stored with the table schema and surfaces in the Operations API's `describe_table` output.
+
+The declaration can equivalently be made as a `static cacheControl` property on an exported JavaScript resource class:
+
+```javascript
+export class Product extends tables.Product {
+ static cacheControl = 'public, max-age=60';
+}
+```
+
+See [REST Headers / Cache-Control](../rest/headers.md#cache-control) for the full caching behavior and how `Cache-Control` interacts with authentication headers.
+
### `@export`
Exposes the table as an externally accessible resource endpoint, available via REST, MQTT, and other interfaces.
diff --git a/reference/http/configuration.md b/reference/http/configuration.md
index bb77b328..ab9d85a5 100644
--- a/reference/http/configuration.md
+++ b/reference/http/configuration.md
@@ -159,6 +159,8 @@ Default: `"Accept, Content-Type, Authorization"`
Comma-separated list of headers allowed in the [`Access-Control-Allow-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) response header for OPTIONS (preflight) requests.
+ When CORS is enabled, Harper emits `Vary: Origin` on all responses (including no-origin requests whose `Access-Control-Allow-Origin`-less variant is also origin-dependent), so that shared caches key responses correctly by origin.
+
## Session Affinity
### `http.sessionAffinity`
diff --git a/reference/rest/headers.md b/reference/rest/headers.md
index 818398eb..7bef6be5 100644
--- a/reference/rest/headers.md
+++ b/reference/rest/headers.md
@@ -20,6 +20,39 @@ These headers are included in all Harper REST API responses:
| `etag` | `"abc123"` | Encoded version/last-modification time of the returned record. Used for conditional requests. |
| `location` | `/MyTable/new-id` | Returned on `POST` responses. Contains the path to the newly created record. |
+## Cache-Control
+
+
+
+Harper applies a tiered Cache-Control policy to REST responses.
+
+### Anonymous reads
+
+For tables (or JS resource classes) that declare a `cacheControl` value, Harper emits that value verbatim on anonymous (unauthenticated) GET/HEAD `200`/`304` responses:
+
+```
+Cache-Control: public, max-age=60
+```
+
+See [`@table(cacheControl: "...")`](../database/schema.md#cachecontrol) for how to declare this value in your schema.
+
+### Authenticated reads (identity floor)
+
+Any response where a principal was resolved — or where credentials were rejected (including `401` and the login-redirect `302`) — receives an identity floor regardless of any table declaration:
+
+```
+Cache-Control: private, no-cache
+Vary: Authorization
+```
+
+When cookie-based sessions are in use, `Cookie` is appended to `Vary`.
+
+An app may explicitly set a `public` or `s-maxage` directive in a resource handler to opt an authenticated response into shared caching (per RFC 9111). The opt-in is trusted, except on `401` responses: a rejected request always receives `private, no-cache`.
+
+### CORS responses
+
+When CORS is enabled, responses that reflect an `Origin` header also emit `Vary: Origin` so that shared caches key correctly by origin. See [CORS configuration](../http/configuration.md#cors).
+
## Request Headers
### Content-Type
diff --git a/reference/static-files/overview.md b/reference/static-files/overview.md
index ee255067..d1ccd553 100644
--- a/reference/static-files/overview.md
+++ b/reference/static-files/overview.md
@@ -74,6 +74,40 @@ In addition to the standard `files`, `urlPath`, and `timeout` options, `static`
- **`notFound`** - `string | { file: string; statusCode: number }` - _optional_ - A custom file (or file + status code) to return when a path is not found. Useful for serving a custom 404 page or for SPAs that use client-side routing.
+### Cache Headers
+
+
+
+Static files are served before authentication runs, so they are public by construction. Use the following options to control the `Cache-Control` header Harper emits for served files:
+
+- **`maxAge`** - `number` (seconds) or `string` (duration) - _optional_ - Freshness lifetime for served files. A number is treated as seconds; a string like `'5m'` or `'1d'` is parsed as a duration. Emits `Cache-Control: public, max-age=`. Defaults to `0` (revalidate on every request via ETag/Last-Modified). Malformed values throw at startup rather than silently degrading. Duration suffixes are `y` (year), `M` (30-day month), `d` (day), `h` (hour), and `m` (minute) — note the case-sensitive distinction between `M` (month) and `m` (minute).
+
+- **`immutable`** - `boolean` - _optional_ - When `true`, appends the `immutable` directive to the `Cache-Control` header. Use this for content-hashed assets whose URL changes when the content changes, so browsers and CDNs never revalidate them. Defaults to `false`.
+
+- **`cacheControl`** - `string | false` - _optional_ - Full `Cache-Control` override string. Takes precedence over `maxAge` and `immutable` when set. Set to `false` to suppress the `Cache-Control` header entirely.
+
+- **`cacheOverrides`** - `object` - _optional_ - A map of glob pattern → per-file cache options (any of `maxAge`, `immutable`, `cacheControl`), letting specific files opt out of the top-level defaults. The typical case is long-lived `immutable` defaults for content-hashed assets while `index.html` gets a short window or `stale-while-revalidate`. Patterns use the same [`micromatch`](https://github.com/micromatch/micromatch) engine as `files`, and are matched against both the request URL path (relative to the mount) and the served file's basename — so `index.html` also targets the directory-index (`/`) response. Entries are tested in config order and the **first match wins**; each entry is a partial — options it sets replace the top-level default, options it omits are inherited, with the same `cacheControl`-over-`maxAge`/`immutable` precedence.
+
+The `notFound` fallback always uses `max-age=0` regardless of `maxAge`, which is correct for SPA index fallbacks — a `200 index.html` response should revalidate so updated builds are picked up promptly.
+
+Example: serve a build directory with long-lived immutable caching for hashed assets, while the entry `index.html` revalidates every request (optionally serving stale content while it does):
+
+```yaml
+static:
+ files: 'web/**'
+ maxAge: '1y'
+ immutable: true
+ cacheOverrides:
+ # First match wins; index.html is matched by basename on the `/` serve.
+ 'index.html':
+ cacheControl: 'public, max-age=0, stale-while-revalidate=60'
+ '*.html':
+ maxAge: '5m'
+ immutable: false
+```
+
+> `stale-while-revalidate` support varies by CDN — CloudFront and Cloudflare honor it, Azure CDN does not. Where it is unsupported the directive is simply ignored (the resource is treated as `max-age=0`), so it is safe to set.
+
## Auto-Updates