In a previous post about where the Community edition's limits actually sit, I mentioned a workaround for the webhook trigger being a paid feature: create a role whose write permission is scoped to a single table, issue an API key for it, and have the external system POST to the REST API directly. Someone on the official forum runs exactly that in production, receiving pushed data from a bank.
Recommending something without testing it sat badly with me, so I built it and hammered on it. The good news: the permission model holds up exactly as advertised. The less good news: three operational behaviors caught me out, and two of them let you end up with keys that look alive in the list while being dead in practice.
Test setup: NocoBase 2.1.23 (official Docker image) + PostgreSQL 16, driven entirely through the REST API. Where behavior was surprising I read the implementation in
@nocobase/plugin-api-keysand@nocobase/aclto confirm the mechanism.Most of what follows bites when you automate key issuance through the API. Creating keys by hand in the admin UI avoids all but one of them — and that one is the most consequential. There's a routes-vs-pitfalls table near the end.
An API key is a role, wearing a token
A NocoBase API key carries no permissions of its own. It's a JWT that encodes which user, acting as which role:
{"userId": 2, "roleName": "integration", "iat": 1785120716, "exp": 33342720716}
Two things to notice. First, the key's authority is exactly the role's authority — nothing more, nothing less. Second, there's no jti — no per-key identifier. That absence causes the third surprise later.
(An "unlimited" expiry still writes an exp; the implementation just substitutes a date far in the future.)
Scoping works as advertised
I built a role allowed to do exactly one thing — create on an inbound_orders table — and issued a key for it:
| Request with that key | Result |
|---|---|
create on the permitted inbound_orders
|
200 |
create on a different table |
403 |
list on that same inbound_orders
|
403 |
list on users
|
403 |
Note the third row: same table, but read access is refused because only create was granted. The granularity is per-action, not per-table. As a credential to hand an external system, that's tight enough to be comfortable.
Role edits take effect immediately, without reissuing
I left the key untouched and changed the role instead:
grant internal_memos:create to the role → same key, create → 200
revoke it again → same key, create → 403
No redistribution needed when requirements change. The flip side is worth stating plainly: widening a role widens every key already issued for it. Use one role per integration.
Letting regular users issue keys — and the ACL rule that blocks it
By default only administrators can create keys — a regular account gets 403 No permissions. To open it up, grant the role the pm.api-keys.configuration snippet. That's where I lost some time.
The stock role carries ["!pm", "!pm.*", "!ui.*"]. Adding the specific grant on top of that does nothing:
| Snippet configuration | Result |
|---|---|
!pm, !pm.*, !ui.*, pm.api-keys.configuration
|
403 |
pm.api-keys.configuration, !pm, !pm.*, !ui.* (order reversed) |
403 |
!pm, !ui.*, pm.api-keys.configuration (drop !pm.*) |
200 |
The ACL implementation collects allows and denies separately, then folds them like this:
// from @nocobase/acl, effectiveSnippets()
const effectiveSnippets = new Set(
[...allowedSnippets].filter((x) => !rejectedSnippets.has(x))
);
Anything matched by a deny is dropped, no matter how specifically it was allowed — and order is irrelevant. If your instinct comes from IAM or CSS, where specificity or ordering decides, that instinct is wrong here. You can't carve an exception out of a wildcard deny; you have to narrow the deny itself.
Surprise 1: omit the role and you get a cheerful 200 with nothing behind it
This one is specific to the API — the UI has a mandatory role selector.
Leave role out of the create request and you get:
POST /api/apiKeys:create {"name": "my-key", "expiresIn": "never"}
→ HTTP 200
→ {}
Success status, no key, no error. The implementation explains it:
// @nocobase/plugin-api-keys, create
async function create(ctx, next) {
const { values } = ctx.action.params;
if (!values.role) {
return; // returns without a word
}
If your provisioning script treats 200 as "key created", it will sail past this. Check that the response body contains token, not just the status code.
Surprise 2: nobody can inventory the keys — not even root
Of the three, this is the one that applies whichever route you use, and it has the largest operational consequence.
Keys issued by a regular user simply don't appear in an administrator's list:
the user's own list : ['integration-key', 'clean-key', 'gov-key', ...]
the administrator's list : ['admin_api_key'] ← only their own
rows actually in the DB : 14
My first thought was that I'd tested with an underpowered account. I hadn't: it held root, admin and member, and passing X-Role: root explicitly returns the same single key. The reason is a server-side middleware that rewrites the request unconditionally:
// applied to list / destroy
ctx.action.mergeParams({ filter: { createdById: ctx.auth.user.id } });
No role check anywhere, which means no amount of privilege routes around it — and because the admin UI calls the same REST endpoint, using the UI doesn't help either.
As "you can't read other people's credentials", this is sound design. As operations, it means there is no way to answer "how many keys are live right now, and who holds them?" from inside the product. If you need that answer, query the apiKeys table directly (it carries createdById). And if you're considering opening key issuance to regular users, weigh it against this: you'd be handing out credentials you cannot subsequently enumerate.
Surprise 3: keys issued in the same second are the same key
Also an automation-only problem in practice — issuing two keys within one second by hand is difficult. It's still the one that fooled me longest.
I issued two keys back to back for the same user, role and expiry, then compared the token strings:
token(twin-a) == token(twin-b) → True
Look again at the payload: userId, roleName, iat, exp, and no jti. Since iat has one-second resolution, two keys created inside the same second with the same parameters serialize and sign to a byte-identical string. Space them more than a second apart and they differ.
Because both carry the same role, this isn't privilege escalation. The damage shows up at revocation time:
call the API with twin-b → 200 (alive)
revoke twin-a → 200
call the API with twin-b → 401 ← never touched it
is twin-b still listed? → True ← still looks alive
Revocation works by adding the token string to a blocklist, so a second record holding the same string dies with it. Issue keys one at a time, with a gap.
The same asymmetry, from the admin side
An administrator can't see another user's keys, but the id is readable straight from the apiKeys table. Point the revoke endpoint at that id and:
| Check | Result |
|---|---|
| Row still in the database | yes |
| Still listed for its owner | yes |
| Key still usable | 401 |
Revocation happens in two steps, and they disagree about ownership. The first — blocking the token — looks the row up with a raw findById that skips the ownership filter. The second — deleting the row — goes through the filtered action from Surprise 2. So the credential dies while its record survives.
The operational takeaway is short: once a key starts returning 401, stop trusting the list. A row being there doesn't mean the credential works. Reissue and move on.
Which of these actually apply to you
| Behavior | Issuing via API | Issuing via UI | Why |
|---|---|---|---|
| 1. Silent 200 with no key | applies | not applicable | the UI requires a role selection |
| 2. Keys can't be inventoried | applies | applies | enforced server-side; the UI uses the same endpoint |
| 3. Same-second token collision | applies | effectively not | issuing twice within a second by hand is impractical |
| 3b. Revoked-but-still-listed | applies | not applicable | admins can't see others' keys to target an id
|
If you provision keys by hand, only the second one is your problem — but it's a real one, because it quietly invalidates the assumption that the admin console shows you what's out there. Start automating issuance and the rest arrive together. That transition is exactly when this post is worth rereading.
Checklist
- One role per integration. Widening a role widens every key already issued under it.
-
Scope by action, not just by table. A receive-only integration should not be granted
list. - Don't try to except your way out of a wildcard deny — deny wins, regardless of order.
-
When provisioning via API, always send
role, and verifytokenis in the response. A 200 alone proves nothing. - Issue keys one at a time, spaced more than a second apart.
- Track issued keys outside the product. The admin console cannot enumerate them, so keep a register — or query the database.
(Measured on 2.1.23 / PostgreSQL 16. Behavior may change in future versions.)
Top comments (0)