01 / Give the caller a stand-in
The viewer still asks to read.
A direct archive reader is a reasonable starting point for trusted internal code. It accepts a document ID and returns the document or an error. Once different readers need different access, a check before each call seems like a small addition.
Then a preview endpoint appears, followed by an export action. Each caller needs the same rule. Add caching at one call site and it becomes easy to return a saved document before reaching the permission check.
A proxy is a stand-in that offers the interface a caller expects while controlling access
to another object or resource. Here, both the archive and its proxy implement DocumentReader.read(request). Give the viewer the proxy as its reader. The
viewer keeps its ordinary read call; the proxy decides whether to refuse, return a cached
copy, or ask the archive.
We will build a protection proxy, then add caching. The archive holds three local records, and reader identities and grants are simulated, so every permission check and archive read stays visible.
Document viewer
Asks its reader for a document. Handles success or a returned error.
Archive proxy
Checks current access. Reuses a successful copy or forwards an allowed miss.
Internal archive
Owns the source records. Returns a document copy or an archive error.
02 / Follow the order
Having the document does not answer who may read it.
The proxy checks the reader’s current grant before looking in its cache. A denial ends the request there. An allowed hit returns a copy. An allowed miss reaches the archive and saves the result only when it succeeds.
The cache belongs to this proxy instance and is shared across its readers. It uses the exact document ID as its key because every authorized reader receives the same content. Ari and Bo can reuse one cached brand guide; Bo still cannot read Ari’s cached launch plan.
| Request | Current access | Result | New archive reads |
|---|---|---|---|
| Ari opens it | Allowed | Fetch and cache the plan | 1 |
| Ari opens it again | Allowed | Cached plan | 0 |
| Bo opens it | Denied | Forbidden, despite the cache | 0 |
| Revoke Ari’s grant; Ari tries again | Denied | Forbidden, despite the cache | 0 |
Four permission checks. One cache hit. One archive read. Revoking access changes the next read even though the cached plan remains. It cannot erase a copy Ari already received.
“Same interface” gives the caller a stable way to ask and handle the answer. It does not promise identical timing, freshness, or permission outcomes to the raw archive. Those behaviors are part of the reader’s contract too.
03 / Read the shape
One reader contract, two implementations.
Start with the basic protection wrapper: deny or forward. In the practical form, follow the
permission check past the cache lookup to the origin read. The call-site view shows the
trusted owner wiring these objects together and passing the proxy to showDocument.
That caller prints either the document body or the returned error. It needs no “is this a proxy?” branch. Complete files include the records, mutable access rules, archive, and example invocation. Every comparison program runs the four requests from the table.
The archive and protection proxy satisfy one DocumentReader contract. The proxy either refuses the read or forwards the same request. Supporting records and policy types appear in the complete file.
export interface DocumentReader {
read(request: ReadRequest): ReadResult;
}
export class ProtectionProxy implements DocumentReader {
#origin: DocumentReader;
#rules: AccessRules;
constructor(origin: DocumentReader, rules: AccessRules) {
this.#origin = origin;
this.#rules = rules;
}
read(request: ReadRequest): ReadResult {
if (!this.#rules.canRead(request.actor, request.id)) return { ok: false, error: 'forbidden' };
return this.#origin.read(request);
}
} type DocumentReader interface {
Read(ReadRequest) (Document, error)
}
type ProtectionProxy struct {
origin DocumentReader
rules *AccessRules
}
func (p *ProtectionProxy) Read(request ReadRequest) (Document, error) {
if !p.rules.CanRead(request.Actor, request.ID) {
return Document{}, errors.New("forbidden")
}
return p.origin.Read(request)
} Reading the TypeScript
implements DocumentReader makes the shared shape explicit. TypeScript
interfaces are structural: a compatible read method satisfies this
contract. The ok field distinguishes a document from an expected failure before
the caller reads the corresponding fields.
A cache entry is a document object, so an empty body still counts as a hit. Object
spreads copy the record into and out of the cache. All fields here are strings; this shallow
copy is sufficient for these records. Adding a nested mutable object would require revisiting
that boundary.
The # fields, such as #cache and #rules, stay
inside their instance. Type annotations and private fields do not turn an in-browser
object into an authorization service; the real boundary still belongs on the server.
Reading the Go
DocumentReader is satisfied implicitly by a matching Read method.
The caller accepts the interface; constructors return pointers to concrete implementations.
Pointer receivers let repeated reads update the same cache and counters.
The map lookup’s ok reports presence independently of the body’s contents.
Returning a Document copies its string fields, while the second return value
carries an error. These maps and counters are used sequentially, without goroutine synchronization.
Reading the PythonProtocols, dataclasses, and explicit copies
Python’s Protocol describes the reader contract without a shared base class.
The result is a small union of success and failure objects, so the caller checks which answer
it received before reading a document or error.
The archive and cache copy the mutable document dataclass at their boundaries. The proxy returns an authorized copy, while a failed origin result is returned unchanged and is not retained. Python’s annotations do not add synchronization to the maps.
04 / Predict, change, observe
Warm the cache. Change the reader.
Watch the recorded reads and revocation, or step through their results. In Try it, start with Ari and the launch plan. Predict a document and read it twice. Then select Bo without clearing the cache. Predict again. The path below the controls shows which step handled the latest request; the counters accumulate until reset.
Switch the proxy order to the deliberately broken cache-first version. This starts a fresh archive. Let Ari warm the launch plan, then let Bo ask for it. A cold denial can look correct while a warm cache quietly skips the check.
A warm copy still needs permission.
- 1 / PermissionNot calledGrant present
- 2 / CacheNot called0 retained
- 3 / ArchiveNot calledOnline
Read to inspect the result.
Permission comes first.
Ari can read the launch plan. No copy has been cached yet.
Reduced motion: choose a scene to see its completed state.
Read this scene
Ari can read the launch plan. No copy has been cached yet.
Current grant: allowed. Cached: none. Permission checks 0, cache hits 0, archive reads 0. Result: no current response.
Try revocation too: warm a document as Ari, then uncheck Ari’s grant and read again. The guarded proxy denies the new request. The broken version keeps returning its copy. Changing permission need not delete content to stop future reads through a correctly ordered gate.
Finally, warm an allowed document and take the archive offline. The guarded cache can still serve that authorized read. Clear the cache and the next allowed read fails; a later attempt reaches the archive again because failures are not cached. The empty note is a successful document with an empty body, so it is cached normally.
The browser lab runs the TypeScript implementation above, plus a browser-only broken ordering for contrast; the other language files show the same reader contract natively.
05 / Review a decision
Trace the path that can return the document.
A check is useful only when every protected return path goes through it. Review the ordering, the references given to the caller, and the responsibility behind the wrapper.
06 / Give it a real job
The owner chooses which reader leaves the room.
Imagine a server endpoint behind a document viewer’s Open action. Authentication supplies the actor; the endpoint supplies the requested document ID. A trusted composition root—the code that assembles the service—owns the internal archive, current policy, and retained proxy. The endpoint receives the proxy as its reader.
The actor string in our example represents that trusted request context. Accepting an unchecked actor from a browser would let the caller choose whose permissions to use. Putting the archive data and the check in the browser also gives the user both sides of the gate.
If the preview or export path receives the raw archive, it can skip the proxy entirely. Keep that reader internal and ensure every protected entry point applies the policy. OWASP’s authorization guidance calls for checking authorization on every request and denying by default. Our placement before every cache return is an application of that rule. Read the authorization guidance.
Now the launch plan changes. Our cached body stays stale until clear(); that
method drops copies without changing grants or historical counters. Content freshness and
permission freshness are separate decisions. This example reads the current in-memory grant
every time, but a real policy service can have its own stale data and failure modes.
Before this becomes a shared service
Define the cache’s identity and lifetime. Document ID is enough for our three shared records. If a response varies by workspace, revision, locale, or reader, the key or cache scope must include every value that changes the content. Keep the permission check even with a more specific key. Add capacity and freshness policies before retaining an open-ended archive.
Decide how failures age. This proxy retains only successful reads. Offline and missing-document results pass through and are retried on later allowed requests. That is easy to explain, but repeated failures can repeatedly load a real service. Any negative caching or retry policy needs an explicit lifetime and must preserve access checks.
Make concurrent behavior explicit. Two asynchronous misses could duplicate a fetch; mutable maps need protection in concurrent native code. A grant could change while a network read is in flight. Sharing pending work and deciding when authorization takes effect need their own design.
Count decisions as well as reads. The counters record exact calls. A real service may record allowed and denied decisions independently of origin reads. An authorized cache hit is still a read worth accounting for.
Build UIs?$state hands you a Proxy and React holds the object itself, and one day an integration will need a view of your state it cannot change.
Where it already is in your components
In Svelte you write todo.done = true and the row updates. In React you never
write that; you give the setter a new object. Both habits come from whether a stand-in
sits between you and your state. Svelte’s docs say that “If $state is used with an array or a simple object, the result is a deeply
reactive state proxy.” It is a JavaScript Proxy offering your
object’s own interface. Your assignment, or a todos.push(…), reaches its set trap, which records the value and updates whatever on the page read it.
Its limits follow from where it stands. $state hands you the proxy, not your
object: in a Svelte 5.57 mount, $state(original) === original was false, and
the development build warned that “proxies and the values they proxy have different
identities.” Writing to original after the page rendered changed nothing on
screen, and reading through the proxy still gave the old value. The docs note the other
direction: “When you update properties of proxies, the original object is not mutated.” An object you push is wrapped the same way, so writing to the todo you pushed changed nothing either. Class instances get no stand-in at
all. “Class instances are not proxied.” $state(new Reminder(…)) returned the same instance, and setting its plain done field left the row saying To do; declared as done = $state(false), it updated.
React puts nothing in front of your object. Its guide to updating objects in state says React “does not need to hijack their properties, always wrap them into Proxies, or do other
work at initialization,” and so, “without using the state setting function, React has no idea
that object has changed.” Calling the setter with the object you mutated does not help: React
will “ignore your update if the next state is equal to the previous state,” as determined by Object.is. In a React 19.1.0 development mount, toggling by
mutation and calling setTodos(todos) rendered the list zero times across
three clicks, with or without StrictMode, and the next unrelated update showed the mutated
value. Replacing the array with a mapped copy rendered it. Copying state out of a proxy is Prototype’s question.
When you have to own it
Now your calendar app gets integrations. A travel-time widget, written by a vendor or
another team, renders on the event page. It needs the start time and location. It has no
business with the attendees’ email addresses or your private notes, and it should never
move the meeting. Hand it a stand-in: a Proxy over the event that answers reads
for the fields its permissions list and refuses every write. That is this lesson’s protection
proxy, with a property read as the request.
Every way of looking has to get the same answer, and each is its own trap. In Chromium 153, get and ownKeys traps hid notes from view.notes, Object.keys, JSON.stringify, and spread, and a has trap made 'notes' in view false. Without a getOwnPropertyDescriptor trap, though, asking for the descriptor returned the
notes. Without traps for them, Object.defineProperty, delete, and Object.setPrototypeOf went straight through to the event.
A permitted array came back as the app’s own array, so the widget could push into it. The helper
below traps each of those and wraps nested values too.
Its target is an empty object, not the event, because the engine checks a proxy’s answers
against its target. Over frozen state, which Immer’s produce returns, a get trap that hid a field threw “'get' on proxy: property 'notes'
is a read-only and non-configurable data property on the proxy target but the proxy did
not return its actual value”, and filtering ownKeys threw as well.
// A read-only view of app state for code you did not write, such as a calendar
// integration. It reads only the fields its permissions list, and every write throws.
export type PluginView<T, K extends keyof T> = { readonly [P in K]: T[P] };
const nestedViews = new WeakMap<object, object>();
export function createPluginView<T extends object, K extends keyof T & string>(
state: T,
fields: readonly K[]
): PluginView<T, K> {
const allowed = new Set<PropertyKey>(fields);
return readOnlyView(state, (key) => allowed.has(key)) as PluginView<T, K>;
}
function readOnlyView(source: object, allowed: (key: PropertyKey) => boolean): object {
// The target is a fresh empty object or array, never the state. With frozen state
// as the target, a trap that hides a field breaks a Proxy invariant and throws.
const target = Array.isArray(source) ? [] : {};
const visible = (key: PropertyKey) => allowed(key) && Object.hasOwn(source, key);
const refuse = (): never => {
throw new TypeError('Integrations can read this event but not change it.');
};
return new Proxy(target, {
get(target, key, receiver) {
if (visible(key)) return wrap(Reflect.get(source, key));
// Hidden fields read as undefined; toString and map still come from the prototype.
return key in target ? Reflect.get(target, key, receiver) : undefined;
},
has: (target, key) => visible(key) || key in target,
// Object.keys, JSON.stringify, and spread all ask these two traps.
ownKeys: () => Reflect.ownKeys(source).filter(visible),
getOwnPropertyDescriptor(target, key) {
if (!visible(key)) return undefined;
const own = Reflect.getOwnPropertyDescriptor(source, key);
if (!own) return undefined;
// An array target has its own non-configurable length, and the report has to
// agree with it. Everything else is reported as configurable, as the target allows.
const fixed = Reflect.getOwnPropertyDescriptor(target, key);
return fixed
? { ...fixed, value: own.value }
: { ...own, value: wrap(own.value), configurable: true };
},
// Without these traps a write would land on the empty target: the state would not
// change, the write would seem to succeed, and the next read of that field would throw.
set: refuse,
defineProperty: refuse,
deleteProperty: refuse,
setPrototypeOf: refuse,
preventExtensions: refuse
});
}
// An allowed field can hold an array or object. Hand out a read-only view of that
// too, the same one each time, so an integration cannot push into the app's array.
function wrap(value: unknown): unknown {
if (typeof value !== 'object' || value === null) return value;
let view = nestedViews.get(value);
if (!view) {
view = readOnlyView(value, () => true);
nestedViews.set(value, view);
}
return view;
}
In Svelte the view layers over $state without losing tracking. The event prop
is the parent’s state proxy, and the widget’s reads go through the view into it: in a
Svelte 5.57 mount, moving the meeting updated the widget, and a $derived view followed a replaced event where a view built once kept showing
the old one. Wrap the state proxy, as the docs advise: “If you need to use your own proxy
handlers in a state proxy, you should wrap the object after wrapping it in $state.” Wrapped the other way, a write through the state proxy never reached
the inner set trap. React treats the view as any other object, so useMemo keyed on event builds a new one for each new event;
keyed on nothing, the widget kept the first event. A write during render threw to the
nearest error boundary in React and to <svelte:boundary> in Svelte.
A Proxy in the page shapes an interface; it is not a security boundary. Code
running in the same page can reach around it. In Chromium, a widget that replaced Reflect.get before its next read was handed the raw event, attendees and all,
by the helper’s own trap. It could also read the notes from the page. For code you do not
trust, run it in a sandboxed iframe and post it only the permitted fields: a frame with sandbox="allow-scripts" that read parent.document got a SecurityError. It is the rule
from this section again. A check protects only what the caller cannot reach without it,
and whatever the page holds, the server must still decide what reaches the page.
A todo list toggled by mutating the todo. Svelte’s $state hands you a proxy that sees the write, while a class instance it does not wrap stays on To do; React holds the object itself, and setting the same array renders nothing.
import { useState } from 'react';
import { initialTodos, type Todo } from './components/todos';
export function TodoList() {
const [todos, setTodos] = useState(initialTodos);
function toggle(todo: Todo) {
// State holds the object itself, with no proxy in between, so nothing
// notices this write.
todo.done = !todo.done;
// The same array as the last render. Object.is finds no change, React skips
// the render, and the row keeps saying To do. Replace instead:
// setTodos(todos.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)));
setTodos(todos);
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<button type="button" onClick={() => toggle(todo)}>
{todo.done ? 'Done' : 'To do'}
</button>{' '}
{todo.text}
</li>
))}
</ul>
);
}
07 / Recognize the family
The stand-in can do more than guard a document.
A protection proxy checks access. A caching proxy reuses a result. A remote proxy represents something reached elsewhere. A virtual proxy delays creating or loading its subject. These responsibilities can combine, and their order can change the result—as our cached launch plan demonstrates.
A reverse proxy forwards HTTP work.
Go’s httputil.ReverseProxy is an HTTP handler that forwards an incoming request
to another server and sends its response back to the client. The client addresses an intermediary
that reaches the origin on its behalf, with no document cache or permission policy of its own.
JavaScript also has a Proxy constructor.
JavaScript’s built-in Proxy can intercept operations such as reading or assigning
a property. That mechanism lets an object stand in for a target at the language level. Our example
uses ordinary methods and classes instead.
08 / Make the call
Choose the wrapper for the decision it owns.
Consider a proxy when callers should keep a stable interface while access, fetching, or representation needs a central owner. It is especially useful when a new caller should inherit those rules by receiving the same reader.
Keep a direct reader for trusted internal work when there is no access or indirection policy to centralize. For a service whose authorization already belongs in established middleware, use that boundary consistently. Adding a class is not what makes the checks complete.
| Pressure | Useful connection |
|---|---|
| Represent a resource while controlling how it is reached | Proxy: preserve the reader contract and mediate access. |
| Make an incompatible API fit the caller | Adapter: translate to the interface the caller needs. |
| Attach another responsibility around existing behavior | Decorator: compose additions through a compatible interface. |
| Offer a simpler entry point to several operations | Facade: organize a subsystem behind a focused surface. |
| Postpone setup until first demand | Lazy initialization: a timing decision a virtual proxy can use. |
Proxy and Decorator can look almost identical in code, and a wrapper can serve more than one intent. Describe the actual contract: this reader stands in for the archive and decides whether the document may be returned. The name helps explain that decision; it does not replace it. One operational tie-breaker: a proxy may refuse, defer, or substitute the operation, so Bo asks for the launch plan and never receives it. A decorator always performs the operation it wraps and adds around it.
09 / Take the idea with you
Explain why “already here” still means “forbidden.”
Try it without saying “proxy”: the viewer gets a reader that checks current access before returning anything. A saved copy avoids an archive call. It never supplies permission.
Now transfer the idea. A preview becomes personalized for each workspace. Which cache key or owner must change? A teammate adds an export endpoint. Which reader should it receive? Those two changes test whether you can identify both the content boundary and the access boundary.
Connections to follow nextRelated lessons
Adapter changes how a caller speaks to something. Facade gives a workflow a smaller surface. Lazy initialization changes when setup happens. Proxy keeps the familiar request and puts a decision on its path.