How to Protect Master Templates in a Design API
Protect master templates with object-level authorization, tenant isolation, field allowlists, secure tokens, and tests that prove every denial works.
A master template is protected when every request proves three things: who the caller is, which tenant they belong to, and whether that identity may perform this exact action on this exact template. Enforce those checks on reads, updates, exports, previews, clones, publishes, archives, and deletes. Then apply a second check to sensitive fields such as owner, tenant, publication state, and sharing rules.
An ID is only a selector. It is not permission. OWASP states: “Every API endpoint that receives an ID of an object, and performs any type of action on the object, should implement object level authorization checks.” See OWASP API1:2019.
1. Define what must be protected
Write the resource and action model before writing middleware. A useful baseline is:
| Resource or action | Typical protection decision |
|---|---|
| Read, preview, export | May this identity view this template and its assets? |
| Update design content | May this identity edit ordinary editable fields? |
| Duplicate or clone | May this identity create a derivative, and in which tenant? |
| Publish | May this identity change the template’s public or production state? |
| Share or change permissions | May this identity grant access to other users or tenants? |
| Change owner or tenant | Usually an administrative operation with separate authorization. |
| Archive or delete | May this identity make the source unavailable or destroy it? |
Use deny-by-default rules. Give ordinary editors only the actions they need. Keep cross-tenant administration explicit, separately scoped, and auditable. Do not assume that authorization on a list endpoint protects a detail, export, preview, or mutation endpoint.
2. Build an authorization decision with object and action context
Authenticate first, then authorize. Authentication identifies the caller; authorization decides whether the caller may perform an operation. Use HTTPS and validate access-token integrity, issuer, audience, expiry, and scopes. API keys can identify an integration, but they are not sufficient by themselves for sensitive, high-value resources. OWASP’s API Security Top 10 (2023) treats object-level, property-level, authentication, and function-level authorization as separate risks.
Pass a structured decision input to one policy function:
{
"subject": {"id":"user_42","tenantId":"tenant_a","roles":["editor"]},
"action": "update",
"resource": {"type":"master_template","id":"tpl_123","tenantId":"tenant_a"},
"changes": ["title","layers"]
}
The policy should fail closed when identity, membership, tenant, resource, or action is missing. A request-supplied tenant ID is a value to verify against the authenticated membership, never proof of access.
3. Enforce tenant isolation end to end
Derive tenant context from the authenticated identity and current membership. Preserve it through every layer:
- Database: include tenant predicates in every query; use row-level security or another database boundary as defense in depth.
- Cache: classify entries as global, tenant-scoped, or user-scoped. Include tenant and other authorization-varying attributes in keys.
- Object storage: use enforceable tenant prefixes or separate buckets. Authorize before returning an object or issuing a signed URL.
- Queues and jobs: put verified tenant, subject, resource, and intended action in the job payload. Authenticate the producer and authorize again at consumption.
- Search and analytics: apply tenant filters before aggregation, export, or indexing results.
Complex or opaque IDs do not replace these checks. A globally unique template ID can still be guessed, leaked, or supplied to an endpoint that forgot authorization.
4. Guard protected properties
Object authorization answers “may this caller act on this template?” Property authorization answers “which attributes may they change or see?” Use request schemas or explicit allowlists; never mass-assign request bodies to persistence models.
const EDITABLE_FIELDS = new Set(["title", "description", "layers", "canvas"]);
const PROTECTED_FIELDS = new Set([
"tenantId", "ownerId", "isMaster", "publicationState", "permissions", "audit"
]);
function validatePatch(patch) {
const keys = Object.keys(patch);
const forbidden = keys.filter((key) => PROTECTED_FIELDS.has(key));
if (forbidden.length) {
const error = new Error(`Protected fields: ${forbidden.join(", ")}`);
error.statusCode = 403;
throw error;
}
const unknown = keys.filter((key) => !EDITABLE_FIELDS.has(key));
if (unknown.length) {
const error = new Error(`Unsupported fields: ${unknown.join(", ")}`);
error.statusCode = 400;
throw error;
}
}
Return only fields the caller may see. For browser-facing responses containing sensitive information, consider Cache-Control: no-store as described in OWASP REST Security.
5. Runnable Node.js example
The following Express example demonstrates tenant-aware reads and updates. Replace the in-memory store with your database and make the identity middleware validate your production token issuer, audience, signature, and expiry.
// server.js
import express from "express";
const app = express();
app.use(express.json());
const templates = new Map([
["tpl_a", { id:"tpl_a", tenantId:"tenant_a", ownerId:"user_a", isMaster:true, publicationState:"draft", title:"Brand master", layers:[] }],
["tpl_b", { id:"tpl_b", tenantId:"tenant_b", ownerId:"user_b", isMaster:true, publicationState:"draft", title:"Other tenant", layers:[] }]
]);
// Demo identity. Production code must verify a signed access token.
function requireIdentity(req, res, next) {
const raw = req.header("x-demo-user");
const identities = {
user_a: { id:"user_a", tenantId:"tenant_a", roles:["editor"] },
admin: { id:"admin", tenantId:"tenant_a", roles:["template_admin"] }
};
req.subject = identities[raw];
if (!req.subject) return res.status(401).json({ error:"authentication_required" });
next();
}
function can(subject, action, template) {
if (subject.roles.includes("template_admin") && subject.tenantId === template.tenantId) return true;
if (template.tenantId !== subject.tenantId) return false;
if (action === "read") return true;
if (action === "update") return subject.roles.includes("editor") && template.isMaster;
return false;
}
const EDITABLE = new Set(["title", "description", "layers", "canvas"]);
function checkedPatch(body) {
const keys = Object.keys(body);
const bad = keys.filter((key) => !EDITABLE.has(key));
if (bad.length) return { error: `fields_not_editable: ${bad.join(",")}` };
return { patch: Object.fromEntries(keys.map((key) => [key, body[key]])) };
}
app.get("/templates/:id", requireIdentity, (req, res) => {
const template = templates.get(req.params.id);
if (!template || !can(req.subject, "read", template)) {
return res.status(404).json({ error:"not_found" });
}
res.set("Cache-Control", "no-store");
res.json({ id:template.id, tenantId:template.tenantId, title:template.title, layers:template.layers });
});
app.patch("/templates/:id", requireIdentity, (req, res) => {
const template = templates.get(req.params.id);
if (!template) return res.status(404).json({ error:"not_found" });
if (!can(req.subject, "update", template)) return res.status(403).json({ error:"forbidden" });
const result = checkedPatch(req.body);
if (result.error) return res.status(403).json({ error:result.error });
Object.assign(template, result.patch);
res.json({ id:template.id, title:template.title, layers:template.layers });
});
app.listen(3000, () => console.log("API listening on http://localhost:3000"));
Run it with npm init -y && npm install express, set "type":"module" in package.json, then execute node server.js.
6. Exercise the policy with cURL and Python
These requests verify an allowed same-tenant read, a denied cross-tenant read, and a protected-field update.
# Allowed: user_a owns tenant_a
curl -i -H 'x-demo-user: user_a' http://localhost:3000/templates/tpl_a
# Denied without leaking whether tenant_b's object exists
curl -i -H 'x-demo-user: user_a' http://localhost:3000/templates/tpl_b
# Denied property update
curl -i -X PATCH http://localhost:3000/templates/tpl_a \
-H 'content-type: application/json' -H 'x-demo-user: user_a' \
-d '{"tenantId":"tenant_b"}'
import requests
base = "http://localhost:3000"
s = requests.Session()
s.headers.update({"x-demo-user": "user_a"})
allowed = s.get(f"{base}/templates/tpl_a", timeout=10)
assert allowed.status_code == 200
cross_tenant = s.get(f"{base}/templates/tpl_b", timeout=10)
assert cross_tenant.status_code in (403, 404)
protected = s.patch(f"{base}/templates/tpl_a", json={"ownerId":"user_b"}, timeout=10)
assert protected.status_code == 403
print("authorization checks passed")
7. Describe requirements in OpenAPI
Put authentication and authorization requirements in the API contract so generated clients, reviewers, and tests can see them. The contract does not replace runtime checks.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
paths:
/templates/{templateId}:
get:
security:
- bearerAuth: []
parameters:
- in: path
name: templateId
required: true
schema: { type: string }
responses:
'200': { description: Authorized template }
'401': { description: Missing or invalid credentials }
'404': { description: Not found or intentionally undisclosed }
patch:
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
title: { type: string }
description: { type: string }
layers: { type: array }
canvas: { type: object }
responses:
'403': { description: Action or property not allowed }
8. Test every denial and every allowed path
Authorization regressions often appear when a new endpoint, serializer, cache, or background worker bypasses shared middleware. Create a matrix with two tenants and at least two roles:
- same-tenant read, edit, clone, publish, share, archive, and delete where allowed;
- cross-tenant read and mutation attempts using valid credentials;
- missing, expired, revoked, malformed, and under-scoped tokens;
- protected-field changes for tenant, owner, publication state, master status, permissions, and audit metadata;
- direct detail, export, preview, download, bulk, and asynchronous job endpoints;
- cache hits, signed URLs, search results, and retries after membership removal.
Assert both status and data. A denied request must not reveal foreign content, storage keys, signed URLs, or identifiers through an error body, timing-sensitive lookup, cache, or log. OWASP’s authorization guidance and authorization regression testing guidance recommend testing denied cases alongside legitimate access.
9. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| A user can read another tenant’s template by changing the ID | Object lookup runs before, or without, a tenant authorization check. | Load through an authorization-aware query or check the exact object and tenant before returning it. |
| Editors can change owner or publication state | Mass assignment or a schema that accepts protected properties. | Use an explicit allowlist and separate admin actions. |
| API returns 200 from cache after membership removal | Cache key omits tenant, subject, role, or policy version. | Authorize before cache reads, vary keys by authorization context, and invalidate on revocation. |
| Signed asset URL works after access is revoked | URL lifetime exceeds the revocation model or storage does not recheck scope. | Use short, operation-specific URLs and align expiry with revocation requirements. |
| Worker updates a template from the wrong tenant | Tenant context was dropped from the queue payload or consumer path. | Carry verified context, authenticate producers, and authorize again at consumption. |
| 401 and 403 behavior leaks object existence | Detail endpoints reveal whether an unauthorized ID exists. | Choose a documented disclosure policy; return a uniform 404 where hiding existence is required. |
| Valid JWT is accepted for the wrong API | Issuer or audience is not validated. | Validate signature, trusted issuer, intended audience, expiry, and relevant scopes. |
10. Performance, reliability, and cost decisions
Authorization adds work, but the cost is usually controlled by making policy inputs small and predictable. Fetch the subject’s current tenant membership once per request, use indexed tenant and template columns, and avoid remote policy calls on every field when a locally cached policy version is sufficient. Measure database latency, cache hit rate, policy evaluation time, and queue authorization failures separately.
Prefer correctness over speculative caching. A stale permission cache can disclose or mutate a template after access is revoked. Version policy data, use bounded TTLs, and invalidate on membership, role, owner, or sharing changes. Make mutations idempotent where retries are possible, and record correlation IDs with authorization decisions so an incident can be reconstructed.
There is no source-backed universal performance number for design-template authorization. Choose database row security, a service-layer policy, or both according to tenant risk, operational complexity, auditability, and failure behavior. NIST SP 800-228 Update 1 (March 13, 2026) presents API controls as incremental, risk-based choices across pre-runtime and runtime stages; see the NIST publication.
11. Or skip the browser setup
If your workflow also needs a clean visual check of a published template, ScreenshotNeo provides a website screenshot API. It accepts a URL with one GET request and can return PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server includes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Every feature is included on every plan: full-page and selector capture, device and retina settings, custom CSS and JavaScript, waits, request blocking, headers and cookies, PDF controls, caching, signed links, asynchronous jobs, bulk capture, and usage reporting. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
12. FAQ
Should a master template be immutable?
Often, the source should be immutable to ordinary editors. If edits are required, separate draft editing from publish and keep version history so a bad change can be rolled back.
Is a tenant ID in the URL unsafe?
It is acceptable as a selector, provided the server compares it with verified membership and applies the same rule to database, cache, storage, and jobs. Treating it as proof is unsafe.
Should unauthorized reads return 403 or 404?
Choose deliberately. A uniform 404 can hide whether a foreign template exists; a 403 can be useful when the caller is allowed to know the resource exists. Apply the choice consistently and avoid leaking details in errors.
Do signed URLs solve template authorization?
No. They delegate access for a limited period. Authorize before issuing them, scope them to the exact object and operation, and set expiry and revocation behavior deliberately.
What is the first security test to add?
Create two tenants, give each one template, and assert that a valid user from tenant A cannot read, export, clone, update, publish, or delete tenant B’s template. Then add protected-field and expired-token cases.


