Members, roles & API keys

Three kinds of access exist in a project, and they never blur:

WhoWhat they areHow they authenticate
MembersTeammates working in the dashboardSession sign-in on your workspace
API keysAnything calling from outside the dashboard — your site, servers, scriptsESC_API_KEY: esc_… header
End usersYour site's own customersAuth schemas — a separate system entirely

The workspace owner (the account that created the workspace) bypasses roles and always holds every permission.

Members

Invite a teammate under Members with their email and a role. They receive an invitation email (through your mail integration) with a single-use link where they set their password; from then on they sign in at your workspace's project sign-in page. Invitations expire, can be re-sent, and a member can be removed at any time.

Each project has a limit on how many people can use its dashboard — 3 by default, the workspace owner included, so a new project has room for the owner and two members. Active members and pending invitations each take a seat; suspended members do not, so reactivating one needs a free seat. Members shows how many seats are in use. When they are all taken, remove a member or ask your platform administrator to raise the limit; nobody is removed if a project is over its limit.

Roles and permissions

A role is a named set of permissions. Every project starts with an Administrator role holding everything; create leaner roles for editors, reviewers or integrators. The permission vocabulary:

GroupPermissions
Contentcontent.read .create .update .delete .publish
Schemasschemas.read .create .update .delete
Mediamedia.read .upload .delete
Forms & eventsforms.read .create .update .delete .submit
Emailemails.read emails.send emails.manage
Endpointsendpoints.read .create .update .delete .invoke
End-user authauth.use
Membersmembers.manage
Dashboarddashboard.manage

Two deliberate splits worth noting: **emails.send is separate from emails.read because sending uses your SMTP identity — someone who can read delivery logs cannot necessarily send as you; and endpoints.invoke is separate from endpoint management** so a credential can be allowed to call your APIs without being able to read or rewrite their logic.

Three permissions exist for what your website does, so the key it carries can hold only those:

PermissionLets a key
forms.submit — Submit forms (public website)Post form submissions
auth.use — End-user auth (login/register from your site)Call the auth schema routes
endpoints.invoke — Call custom endpointsCall your custom endpoints

forms.submit and auth.use arrived in 4.0. Administrator roles created before then do not hold them until you tick them in the role — they matter for keys far more than for people, so most projects never need to.

dashboard.manage (Manage dashboard) lets a member change the project's shared dashboard. Viewing the dashboard needs only access to the project, and every widget shows the viewer only what their own role can read. It arrived in 4.1, and roles created before then, Administrator included, do not hold it until you tick it. API keys are never offered it, because the dashboard's routes refuse every key.

Roles can also be scoped to specific schemas, restricting content access to a subset of your content types.

API keys

Under API keys, create a key with a name and a permission list. The secret (prefixed esc_) is shown once at creation and stored only as a hash — copy it then or re-issue.

Every call from outside the dashboard needs one — records and media APIs, form submissions, end-user auth routes and custom endpoints alike. (The dashboard itself uses your session and never needs a key.) Send it in the ESC_API_KEY header:

curl https://<workspace>/api/projects/<project>/schemas/<schema>/records \
  -H "ESC_API_KEY: esc_…"

The header name is case-insensitive (esc_api_key works too). A request without a key (and no dashboard session) is answered 401 with code API_KEY_REQUIRED — on every route, records included; a wrong or revoked key 401 UNAUTHENTICATED; a key lacking the permission a route needs 403; a browser on a site the allowed origins (below) leave out 403 ORIGIN_NOT_ALLOWED.

The old Authorization: Bearer form

Before 4.0 keys were sent as Authorization: Bearer esc_…. That still works for now, but is deprecated: such responses carry Deprecation: true and a Warning header naming ESC_API_KEY, and the key's last use is recorded as Bearer so you can see in the API keys list which callers still need updating. If a request carries both, ESC_API_KEY wins.

The Bearer slot is only read as a key when its value starts with esc_ — on the end-user auth routes and logged-in-user endpoints it carries your end user's token, with the key alongside in ESC_API_KEY.

Behind your own proxy

Header names with underscores are legal, but some reverse proxies drop them by default — nginx does unless underscores_in_headers on; is set in the server block. If a key that works with curl straight to your workspace comes back API_KEY_REQUIRED through your proxy, that is the reason. The platform's own hosting passes the header through untouched.

  • A key's reach is exactly its permission list — a read-only key gets 403 on writes, and a key without endpoints.invoke cannot call any custom endpoint.
  • Keys are project-scoped by construction (they live in the project's own database) and only work on your workspace's domain.
  • Each key records when it was last used, and whether through ESC_API_KEY or the deprecated Bearer form; deactivate or delete revokes it immediately.
  • Anything in browser JavaScript is public. A key your site's pages send can be read by any visitor, so give it only what a visitor could do anyway — forms.submit, auth.use, endpoints.invoke, at most content.read — never content writes, schemas or email, and lock it to your site with its own allowed origins (below). Better still, call from your site's server (or a serverless function) and keep the key out of the browser entirely. Keys for your own backends and scripts stay server-side, always.

Allowed origins

An allowed-origins list says which websites may call a project's API from a browser. It is checked on the server, before a key is verified or any data is read — not only advertised in CORS headers — on every external route: records, schemas and media, form submissions, end-user auth and custom endpoints.

Lists can be set in four places, and a request must pass every one that is set:

LayerWhereCovers
ProjectProject Settings → Allowed origins (owner only)Every external route of the project — the ceiling
API keyOn the key, under API keys (create or edit)Requests made with that key
EndpointThe endpoint's Allowed browser originsThat custom endpoint
Auth schemaThe schema's Application URLThat schema's auth routes

The origin a request is allowed from is the intersection of the lists that are set. A list left empty is not a restriction, so the project list is a ceiling: a key or endpoint list can only narrow it, never add an origin the project leaves out.

Each entry is * (any origin) or a bare origin — scheme, host and an optional port, such as https://www.example.com or http://localhost:5173. No path or query — a single trailing slash is accepted and dropped, so https://www.example.com/ is saved as https://www.example.com. Entries are lowercased, a default port is dropped, duplicates are removed, and a list holds at most 50. An invalid entry is refused on save with 400 INVALID_ORIGIN.

How a request is answered:

  • **No Origin header** — servers, scripts, curl — or a request from your workspace's own origin: no origin check at all. Lists only govern browsers on other sites. "Own origin" means the same scheme, host and port: a dev server on http://localhost:5173 calling an API on http://localhost:3000 is another site, and needs listing like any other.
  • No list set anywhere that applies (the default for records, media and forms): the request runs as before 4.0 but gets no CORS headers, so a browser on another site cannot read the response. Auth routes always have a list — the application URL — and so do endpoints that set one.
  • The origin is in every list that is set: the request runs and the response carries CORS headers for that origin. * is answered by reflecting the caller's origin, never a literal *.
  • A list that is set leaves the origin out: 403 with code ORIGIN_NOT_ALLOWED, before any work is done. The body's layer says which list refused — project, api_key, endpoint or auth_schema — without revealing what the list contains.

Preflight. A browser asks first with an OPTIONS request, and that request cannot carry ESC_API_KEY. It is answered from the project's list and the endpoint's or auth schema's, which allow the ESC_API_KEY and Authorization headers. When neither of those is set, it passes if any active key of the project lists the origin — so a list set only on a key works from a browser without a project list. A refused origin gets an answer with no CORS headers, and the browser stops there. The list of the key actually sent is checked on the real request that follows.

No cookies across sites. Cross-origin responses never allow credentials, so a dashboard sign-in can never be used from another website, and a request that carries the dashboard's session from anywhere but the workspace's own pages — a foreign Origin, or a browser reporting another site, another workspace's subdomain included — is refused (403 ORIGIN_NOT_ALLOWED, layer: session) whatever the lists say.

The key is still the real gate. Browsers send Origin truthfully, but anything else can claim whatever origin it likes, so an origin list stops other websites using a key in their visitors' browsers — it does not stop someone who copied the key from calling with curl. The recommendation follows from that:

  • By default, call from your server and leave origin lists unset for those keys.
  • A key that appears in browser code gets the least it needs — read-only or forms.submit / auth.use / endpoints.invoke — and its own allowed origins naming your site, so it is useless to other websites.

Sensitive areas stay owner-only

Integration credentials (SMTP, S3, Redis) and the database connection are readable and writable only by the workspace owner — no role and no API key can reach them.