· tips · 8 min read
Dynamic Permissions: Control Access to Your Objects with Proxies
Learn how to use JavaScript Proxies to enforce dynamic, fine-grained permissions on objects-role-based reads/writes, revocable access, audit logging, and safe patterns to avoid common pitfalls.

Outcome first: after reading this you’ll be able to wrap any object with a permission-aware Proxy that enforces read/write/delete rules at runtime, updates permissions without rewrapping, supports revocation and audit logging, and avoids the common gotchas that turn a neat idea into a bug.
We’ll build from a small, practical example up to production-ready patterns - code you can copy, adapt, and reason about.
Why do this? Because sometimes you need logic that determines who may read or write a property at runtime - not at build time. Proxies give you that control in JavaScript without changing callers. They sit between code and data and decide what to let through.
What a Proxy gives you - quick overview
A JavaScript Proxy intercepts fundamental operations on objects: property access, assignment, enumeration, deletion, reflection and more. You supply traps (handler methods) to customize those operations. Use Reflect inside traps to forward operations safely.
Key resources to keep at hand:
- MDN Proxy reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
- MDN Reflect reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect
- MDN Proxy.revocable: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/revocable
A minimal permission proxy (read/write/delete)
Start small: enforce per-property read and write permissions. Keep the permission model simple: an object with canRead(prop), canWrite(prop) and canDelete(prop) functions.
function createPermissionProxy(target, permissions) {
const handler = {
get(t, prop, receiver) {
if (typeof prop === 'symbol') return Reflect.get(t, prop, receiver);
if (!permissions.canRead(prop)) {
throw new Error(`Permission denied: read ${String(prop)}`);
}
return Reflect.get(t, prop, receiver);
},
set(t, prop, value, receiver) {
if (!permissions.canWrite(prop)) {
throw new Error(`Permission denied: write ${String(prop)}`);
}
return Reflect.set(t, prop, value, receiver);
},
deleteProperty(t, prop) {
if (!permissions.canDelete(prop)) {
throw new Error(`Permission denied: delete ${String(prop)}`);
}
return Reflect.deleteProperty(t, prop);
},
};
return new Proxy(target, handler);
}
// Usage
const data = { secret: 'top', public: 'ok' };
const permissions = {
canRead: p => p === 'public',
canWrite: p => false,
canDelete: p => false,
};
const proxied = createPermissionProxy(data, permissions);
console.log(proxied.public); // 'ok'
console.log(proxied.secret); // throws: Permission deniedShort, effective. But now let’s make it usable in real scenarios.
Role-based permissions and dynamic updates
Hard-coded functions are fine for examples. In real apps you want roles and the ability to change permissions at runtime.
Pattern: store a mutable permissions object in the closure. Change the object later - the proxy will use the new rules without being recreated.
function rolePermissionsFor(userRole) {
const roleRules = {
guest: { read: ['public'], write: [] },
editor: { read: ['public', 'content'], write: ['content'] },
admin: { read: ['*'], write: ['*'], delete: ['*'] },
};
const rules = roleRules[userRole] || roleRules.guest;
return {
canRead: prop => rules.read.includes('*') || rules.read.includes(prop),
canWrite: prop =>
rules.write && (rules.write.includes('*') || rules.write.includes(prop)),
canDelete: prop =>
rules.delete &&
(rules.delete.includes('*') || rules.delete.includes(prop)),
};
}
// Switchable permissions
const permObj = rolePermissionsFor('guest');
const proxy = createPermissionProxy({ public: 1, content: 'x' }, permObj);
console.log(proxy.public); // ok
// promote user
Object.assign(permObj, rolePermissionsFor('editor'));
console.log(proxy.content); // ok nowBecause the proxy references the same permission functions, updating those functions or their backing data is enough to change behavior dynamically. No rewrapping necessary.
Revocable access (kill a session)
When you need to instantly cut access - a logout, session revoke, or emergency - use Proxy.revocable.
const target = { secret: 's' };
const { proxy, revoke } = Proxy.revocable(target, {
get(t, prop) {
return Reflect.get(t, prop);
},
});
console.log(proxy.secret); // 's'
revoke();
console.log(proxy.secret); // TypeError: Cannot perform 'get' on a proxy that has been revokedCombine revocable proxies with your permission logic to both change allowed operations and destroy access entirely.
Auditing and logging
Proxies are perfect places to insert audit trails without polluting business logic. Keep an append-only log, push events, or call a logger inside traps.
function createAuditedProxy(target, permissions, auditLog) {
return new Proxy(target, {
get(t, p, r) {
auditLog.push({ op: 'get', prop: p, ok: permissions.canRead(p) });
if (!permissions.canRead(p)) throw new Error('Denied');
return Reflect.get(t, p, r);
},
set(t, p, val, r) {
auditLog.push({
op: 'set',
prop: p,
ok: permissions.canWrite(p),
value: val,
});
if (!permissions.canWrite(p)) throw new Error('Denied');
return Reflect.set(t, p, val, r);
},
});
}
const log = [];
const p = createAuditedProxy({ a: 1 }, rolePermissionsFor('editor'), log);
try {
p.a;
} catch (e) {}
console.log(log);Keep logs outside the trap to avoid blocking or leaking synchronous timing info in sensitive systems.
Deep object trees: lazy / selective proxying
Often objects have nested structures. Proxying everything eagerly may be expensive. Use lazy proxies: when a property returns an object, wrap it on-access and cache the wrapper.
function createDeepPermissionProxy(target, permissions, cache = new WeakMap()) {
function wrap(t) {
if (typeof t !== 'object' || t === null) return t;
if (cache.has(t)) return cache.get(t);
const handler = {
get(inner, prop, receiver) {
if (!permissions.canRead(prop)) throw new Error('Denied');
const val = Reflect.get(inner, prop, receiver);
return wrap(val);
},
set(inner, prop, value, receiver) {
if (!permissions.canWrite(prop)) throw new Error('Denied');
return Reflect.set(inner, prop, value, receiver);
},
};
const proxy = new Proxy(t, handler);
cache.set(t, proxy);
return proxy;
}
return wrap(target);
}This pattern prevents wrapping deep trees until required and avoids infinite recursion by caching.
Advanced traps: hiding properties and controlling reflection
You can control what appears in Object.keys, for..in, and other reflection APIs by implementing ownKeys and getOwnPropertyDescriptor traps. That enables property-level hiding.
const handler = {
ownKeys(t) {
// only return keys the caller can read
return Reflect.ownKeys(t).filter(k => permissions.canRead(k));
},
getOwnPropertyDescriptor(t, prop) {
if (!permissions.canRead(prop)) return undefined; // hides it
return Reflect.getOwnPropertyDescriptor(t, prop);
},
};Careful: the Proxy must respect invariants (see pitfalls below). If the target has non-configurable properties, ownKeys must include them.
Pitfalls, invariants and gotchas (must-read)
Invariants: Some traps must preserve target invariants. For example, if the target has a non-configurable, non-enumerable property, you can’t pretend it doesn’t exist in ownKeys/getOwnPropertyDescriptor - doing so throws a TypeError. Read the MDN docs carefully before hiding properties.
Symbols and internals: Interactions with symbols (like Symbol.iterator), prototypes and constructors can be subtle. Usually allow symbol access to pass through unless you specifically intend to intercept those.
Direct target access: A Proxy only controls access to the proxy object. If other code has a direct reference to the original target object, it can bypass the proxy completely. To get effective protection, make the target inaccessible (keep it module-private or in a closure).
Performance: Proxy traps add overhead. Avoid super-fine-grained traps in hot paths. Use lazy wrapping and benchmark.
Asynchronous checks: Traps are synchronous. You can call asynchronous permission checks inside them, but you cannot await inside a trap unless you return a Promise back to the caller - that changes the API and breaks code expecting raw values. Use synchronous caches for permission decisions (e.g., a token that grants/denies) or perform async validation before giving the code a proxy.
Revoked proxies throw on any operation. Make sure callers catch or handle the TypeError produced after revoke().
Non-extensible targets & invariants: If a target is non-extensible or has non-configurable properties, your proxy handlers must reflect that. Violations produce runtime TypeErrors.
Security considerations
Proxies are powerful, but not a magical sandbox. They are best used for convenience, encapsulation, and soft access controls within a trusted runtime. They are not a replacement for true isolation.
- Don’t rely on a Proxy to secure data against malicious code running in the same process that can obtain direct references.
- In multi-tenant or high-security contexts, combine runtime policies with process isolation, real permission checks at API boundaries, and server-side validation.
Example: Role-based API object with revocation and audit
A compact pattern combining many ideas:
function makeSessionResource(target, initialRole, audit) {
const perms = rolePermissionsFor(initialRole);
const handler = {
get(t, p, r) {
audit &&
audit.push({ op: 'get', prop: p, role: initialRole, time: Date.now() });
if (!perms.canRead(p)) throw new Error('Denied');
return Reflect.get(t, p, r);
},
set(t, p, val, r) {
audit &&
audit.push({
op: 'set',
prop: p,
val,
role: initialRole,
time: Date.now(),
});
if (!perms.canWrite(p)) throw new Error('Denied');
return Reflect.set(t, p, val, r);
},
};
const revoked = Proxy.revocable(target, handler);
return {
proxy: revoked.proxy,
setRole(role) {
Object.assign(perms, rolePermissionsFor(role));
},
revoke() {
revoked.revoke();
},
};
}
// Usage
const audit = [];
const session = makeSessionResource(
{ secret: 'x', content: 'y' },
'guest',
audit
);
console.log(session.proxy.content); // maybe ok
session.setRole('admin');
console.log(session.proxy.secret); // allowed now
session.revoke();
// any further access throwsThis gives a session object you can change roles on and kill entirely - plus audit trails of operations.
When to use proxies for permissions (and when not)
Use Proxies when:
- You need runtime, fine-grained control over property-level access.
- You want to add logging/auditing transparently.
- You need revocable access tied to a user/session.
Don’t rely on Proxies when:
- You’re trying to defend against attackers in the same process who can obtain references to the original object.
- Your permission checks are asynchronous and cannot be resolved synchronously.
Final checklist before production use
- Keep the target object inaccessible except via the proxy.
- Respect invariants for non-configurable properties and non-extensible targets.
- Use Proxy.revocable or a similar revoke pattern for session lifecycle.
- Cache and/or precompute permission decisions for hot paths.
- Log audit entries outside traps when possible to avoid blocking.
- Benchmark and profile; proxies have runtime cost.
Further reading
- Proxy on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
- Reflect on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect
- Proxy.revocable on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/revocable
If you apply these patterns thoughtfully, Proxies will let you add a compelling, dynamic layer of permissions and control around your objects - flexible, auditable, and revocable.



