DEV Community

stale_orbit
stale_orbit

Posted on

NocoBase API keys: tight permissions, loose key management

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-keys and @nocobase/acl to 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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
);
Enter fullscreen mode Exit fullscreen mode

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
→ {}
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. One role per integration. Widening a role widens every key already issued under it.
  2. Scope by action, not just by table. A receive-only integration should not be granted list.
  3. Don't try to except your way out of a wildcard deny — deny wins, regardless of order.
  4. When provisioning via API, always send role, and verify token is in the response. A 200 alone proves nothing.
  5. Issue keys one at a time, spaced more than a second apart.
  6. 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.)

References

Top comments (0)