API tokens
Mint, scope, rotate and revoke the scoped API tokens the gRPC door requires — from a shell or from the dashboard.
Mint, scope, rotate and revoke the scoped API tokens the gRPC door requires — from a shell or from the dashboard.
The gRPC door authenticates every caller with a scoped API token, presented
as an authorization: Bearer <token> metadata entry or HTTP header. Tokens live
in the database rather than in the environment, which is what makes each of the
next three sentences true: one can be revoked without touching any other, one
can be granted less than the whole surface, and one carries a record of when it
was last used.
An unprovisioned door serves nothing. Loopback and Unix-socket binds are not
exempt: a listener with no token minted refuses every call, because a door with
no credential configured is a misconfiguration rather than a permission grant.
Earlier releases accepted a FLEXIQ_GRPC_TOKEN shared secret and let a loopback
bind through anonymously; both are gone, and the variable is refused at boot.
Mint it wherever the server can reach its database. No listener has to be
running — provisioning a credential is not a role, so the command configures
itself from FLEXIQ_DSN, FLEXIQ_BACKEND and FLEXIQ_NAMESPACE alone:
export FLEXIQ_DSN=postgres://user:pass@host/db
export FLEXIQ_NAMESPACE=prod
flexiq-server token create --name my-producer --scope produceIt prints the token to stdout, once:
fqt_9f2c1ab74e05d366.mZ1qgWx8yQ4nR7t0KcV2sJdH6bPfLuA3eXyN5rTiOkThat is the only time it is shown. What the server stores is a SHA-256 digest of the secret half, so the token is not recoverable from the row, from a database dump, or by anyone with admin access. If it is lost, revoke it and mint another.
The token is the only thing on stdout — the confirmation, which carries the id, goes to stderr — so a command substitution captures the credential and nothing else:
export FLEXIQ_TOKEN=$(flexiq-server token create --name my-producer --scope produce)Point it at the same database the server reads. A token minted against another one is minted somewhere nothing checks it, and the door it was meant for will refuse every call made with it.
On Kubernetes, the same command through the running release:
kubectl exec deploy/flexiq-flexiq-server -- \
flexiq-server token create --name my-producer --scope produceThe dashboard does the same three things under Configuration → gRPC tokens,
behind its existing admin role — minting is an operator action, and a producer
credential must never be able to mint itself a wider one. Automation that has
no dashboard session mints over the admin door's token
methods instead, with a token carrying the
tokens scope, and can only mint what that token already holds.
A token grants scopes. They are not a hierarchy, with one exception: produce
also opens everything read does, because it always has.
| Scope | Package | What it opens |
|---|---|---|
produce | flexiq.v1 | Submit, read and cancel work |
read | flexiq.v1, read-only methods | See jobs, queue stats and workflow runs, and watch jobs; never submit or cancel |
execute | flexiq.executor.v1 | Claim work and report on it |
inspect | flexiq.admin.v1, read-only methods; externalscaler | Look at queues, dead letters, workers, schedules and overrides; answer the KEDA external scaler, which carries the token in-band (External scaler) |
admin | flexiq.admin.v1, every other method | Pause queues, replay and purge dead letters, manage schedules and overrides |
tokens | flexiq.admin.v1, the token methods | Mint, list and revoke tokens, never wider or longer-lived than its own; not implied by admin, and not narrowable |
A producer and an executor need opposite permissions, so a token that can poll
for work cannot enqueue it, and a produce token asked for an executor RPC is
refused with PERMISSION_DENIED and the reason SCOPE_DENIED, carrying the
scope it lacked in the error metadata. Repeat --scope to grant several, and
grant each only to something that genuinely needs it.
The producer and operator packages are split by method rather than granted
whole: a method that declares idempotency_level = NO_SIDE_EFFECTS needs read
(producer) or inspect (operator), and every other one needs produce or
admin. A dashboard or an alerting job that watches jobs gets read and can
neither submit nor cancel. A support tool gets inspect and can change
nothing. An operator's token carries both operator scopes:
flexiq-server token create --name ops --scope inspect --scope adminproduce reaches none of it, so a producer credential that leaks costs you an
enqueue path rather than an operator. Token minting over gRPC needs the
separate tokens scope, and the settings store and webhook secrets stay behind
the dashboard's session and role check — they are on no gRPC surface at all.
The admin door is the page for what inspect,
admin and tokens reach.
The table is about calls, not about the schema. Server reflection asks only
that a caller be authenticated, so any row above reads every package — a
produce token can describe the executor RPCs it will then be refused. The
gRPC door's
Reflection, and what it hands out
has the shape of that, and why revoking is the control rather than scoping.
Any grant but tokens can be narrowed to some queues, some tasks, or both. This is how you give a webhook receiver or an untrusted edge
service a token that can enqueue exactly one task and nothing else:
flexiq-server token create --name receipts-webhook \
--scope 'produce:queue=emails,task=send_receipt'A grant is <scope>:queue=<pattern>,task=<pattern>, and either qualifier can be
left out. A pattern is an exact name, a prefix ending in * (emails-*), or
* for every name. * anywhere else is refused, and so are commas, = and
whitespace. Repeat --scope to hold several grants. A call is allowed when any
one of them covers it:
flexiq-server token create --name billing-support \
--scope 'read:queue=billing' \
--scope 'produce:queue=billing,task=refund'The dashboard's token dialog has the same two fields, and they narrow every scope switch.
What a narrowed producer token gets, call by call:
| Call | A queue or task outside the grants |
|---|---|
Enqueue, EnqueueBatch | Refused SCOPE_DENIED, carrying scope, queue and task. A batch is refused whole, before anything is written. The queue checked is the one the job lands in, so an empty queue is checked as default. |
GetJob, CancelJob | JOB_NOT_FOUND — the same answer as a job in another namespace, so a narrowed token cannot probe for ids it cannot reach. |
ListJobs | Must name a queue the grants reach, and a task_name too if the grant names a task. A listing is never filtered after the fact, so a page's size says nothing about rows the token cannot see. |
QueueStats | Must name a reachable queue. A task-narrowed grant is refused, because the counts include every task in the queue. |
WatchJobs | A queue watch must name a queue the grants reach, and a task-narrowed grant is refused. On an id watch, a job outside the grants reads as not found. |
SubmitWorkflow | Every node is checked like an Enqueue, before anything is written. One node outside the grants refuses the whole graph SCOPE_DENIED, carrying node as well as scope, queue and task. A node's compensate task runs on a queue the saga's worker picks, so it must be granted on every queue. |
GetWorkflowRun | Not found, the same answer as a run that does not exist, unless the grants reach the job of every node in the run. A node with no job yet, or whose job retention removed, has no queue to check, so a narrowed token cannot read that run. |
A debounced Enqueue | Refused, whatever the queue. |
Attach | Every task the executor declares must be granted on some queue. One that is not refuses the whole stream, carrying scope and task; it is never trimmed to the granted tasks. A queue grant is held at dispatch: a job on another queue is never sent to that executor. |
Settle, ExtendLease, ReportProgress, WriteTaskLog | Refused, whatever the job. These name a lease, not a task. |
Two more of those are refusals worth knowing about:
unique_key is matched across the whole namespace, so an enqueue can be
deduplicated to an existing job in another queue. When that job is outside a
narrowed token's grants, the enqueue is refused rather than handing the job
back. Nothing was written.One effect crosses the boundary on purpose. Cancelling a job cancels the pending jobs that depend on it, in whatever queue they sit, exactly as it does for any token. Whoever enqueued the dependent chose that edge. A job whose dependency is cancelled could never run anyway, and it would be cancelled the same way if the dependency had failed. Refusing the cancel instead would let a job in another queue stop a narrowed token from cancelling its own work.
The JSON facade calls the same handlers, so it gives the same answers — 403
for a refusal and 404 for a hidden job.
Every token minted before narrowing existed keeps exactly what it had. A grant is stored in the same list as the scope names always were. A server older than this one drops a narrowed grant it cannot read, so rolling back can only take access away from a narrowed token, never widen it.
An executor token can be held to the tasks it was built to run:
flexiq-server token create --name mailer-executor \
--scope 'execute:queue=emails,task=send_*'The executor names its tasks when it attaches, and the server checks that list before anything is registered. It names no queue, so a queue qualifier is enforced when work is placed instead: a job on a queue the grants do not reach waits for an executor whose grants do, and fails back for a retry if none does.
Grants bind only the gRPC door. An executor attached over the socket listener
(FLEXIQ_LISTEN) presents the shared FLEXIQ_ATTACH_TOKEN, which carries no
grants, and may run any task it declares.
An operator token can be held to one team's queues. This one can pause and
resume billing, read and replay its dead letters, and manage the schedules
that fire into it, and it can touch nothing else:
flexiq-server token create --name billing-ops \
--scope 'inspect:queue=billing' --scope 'admin:queue=billing'Each operator method is checked against what it acts on:
| Call | Checked against |
|---|---|
PauseQueue, ResumeQueue, SetQueueOverride, ClearQueueOverride | The queue. A grant narrowed to a task is refused, because these act on every task in the queue. |
SetTaskOverride, ClearTaskOverride | The task, on every queue. A grant narrowed to a queue is refused. |
PurgeDeadLetters | With task_name, the task on every queue. The other forms purge the whole namespace and need a grant that is not narrowed. |
GetDeadLetter, ReplayDeadLetter, DeleteDeadLetter | The entry's queue and task. An entry outside the grants is DEAD_LETTER_NOT_FOUND, the answer an absent one gets. |
GetPeriodicTask, DeletePeriodicTask, PausePeriodicTask, ResumePeriodicTask, TriggerPeriodicTask | The queue and task the schedule fires. One outside the grants is PERIODIC_TASK_NOT_FOUND. |
PutPeriodicTask | The queue and task it would fire, an empty queue checked as default. A name already taken by a schedule outside the grants is refused SCOPE_DENIED, naming only the scope. |
ListQueues, GetThroughput | Must name a queue the grants reach. A grant narrowed to a task is refused, because the counts cover every task in the queue. |
ListDeadLetters, ListPeriodicTasks | Must name a queue, and a task_name too if the grant names a task. |
ListOverrides | Must name a queue or a task_name, each checked as its setter is. Each map then holds only the override named. |
ListWorkers, DrainWorker, the quota calls, ListAuditRecords | Refused. A worker serves several queues, and the quota and the audit trail belong to the whole namespace. |
As on the producer door, a listing is filtered by the query, never after the
page is cut. The filters are optional for an unnarrowed token. In fq, they
are --queue and --task on fq dlq list, fq periodic list and
fq overrides list, and --queue on fq throughput.
A token is bound to the namespace the minting process itself serves, and it is refused by a listener serving another. There is no namespace field on the wire in any RPC, in either package: the namespace comes from the credential and nowhere else.
That is why flexiq-server token create reads FLEXIQ_NAMESPACE and takes no
--namespace argument. A credential for a namespace this deployment does not
schedule would accept enqueues that nothing ever dequeues — a success response,
and no work.
flexiq-server token listID NAME STATUS NAMESPACE EXPIRES SCOPES
9f2c1ab74e05d366 my-producer active prod 87d produce
b1d0e7c4a3f29855 ci-executor active prod 12d executeThe id is public — it is what a listing shows, what revoke takes, and the part
of the token before the dot — so it is safe in a log or a ticket. STATUS is
active, expired or revoked; a revoked row is kept, because it is the
record that the credential existed, who minted it and when it was last used.
--all-namespaces widens the listing past the one this process serves. Without
it, a token belonging to another namespace reads as absent rather than as a
refusal — the same answer revoke gives, so neither command can be used to
probe for the existence of another tenant's credentials.
Every token expires. The default is 90 days and the maximum is 365 — a credential with no end date is a permanent one with extra steps — and a request for longer is refused rather than quietly clamped:
flexiq-server token create --name nightly-batch --scope produce --expires-in-days 30Once a token is inside 30, 20 and 10 days of expiry, the server logs a warning the first time it is used at each threshold. Warning on use rather than on a timer means the credentials that get a reminder are the ones actually carrying traffic, and one that expires unnoticed was one nothing was calling with.
There is no rotate command, because rotation is two commands and an interval you choose:
# 1. Mint the replacement, alongside the one still in use.
flexiq-server token create --name my-producer-2026-09 --scope produce
# 2. Roll your clients onto it. Both work for as long as this takes.
# 3. Revoke the old one.
flexiq-server token revoke 9f2c1ab74e05d366Nothing restarts at any point. The door reads the row per call rather than caching a verdict, so both a new token and a revocation take effect on the next call, everywhere, including on replicas you did not touch.
Check last_used_at before step 3, not after. The dashboard's token table
has the column; it is the only thing that tells you whether a client you forgot
is still presenting the credential you are about to revoke. It is written at
most once per token per minute, so treat it as "used recently", not as a precise
timestamp.
Unlike the attach token or the dashboard admin password, a gRPC token is not a
chart value and not an environment variable. A secretKeyRef is resolved into a
container's environment once at pod start, so rotating one needs a restart; a
token is read from the database on every call, so it does not.
flexiq-server token revoke 9f2c1ab74e05d366A revoked token fails on its next call, with no restart. A revocation from the dashboard, from another pod, or from a shell takes effect immediately everywhere, because there is no cache to invalidate — a cached allow would be a revocation that never takes effect.
What the token did while it was valid is in the
audit trail, under the same id. So are its mint and
its revoke — from the dashboard under the user who did it, from the shell as
cli — as records targeting token:<id>:
fq audit list --token-id 9f2c1ab74e05d366 --all # what it did
fq audit list --target token:9f2c1ab74e05d366 # who minted and revoked itOn the dashboard, What this token did in a token's row menu opens the same trail, filtered to the token — see From the dashboard.
Nothing it does not already know. A missing token, a wrong one, a well-formed
token for an id that was never minted, one under the wrong scheme, an expired
one, a revoked one, one minted for another namespace, and a call to an RPC that
does not exist all answer UNAUTHENTICATED with the reason UNAUTHENTICATED
and the same message.
That is deliberate on both counts: a distinguishable answer tells a client
guessing tokens which guess was closer, and a path answered before
authentication tells it which services the build carries. There are two
exceptions. The two grpc.health.v1 RPCs answer without a credential — see
The gRPC door for why a probe cannot
carry a credential. And the KEDA external scaler reads its token from the
request body, so a tokenless body over its 64 KiB cap is refused with
OUT_OF_RANGE, and one that does not decode with a decode error, before that
token is checked; every scaler request that decodes is authenticated first.
It proves the peer holds a string. It does not encrypt the connection on its
own — flexiq-server can terminate TLS on this door itself
(TLS and mTLS), or leave it to a sidecar
proxy or a service mesh in front of it. Either way the token is what decides
which principal a connection belongs to, not whether the connection can be
read off the wire. Where mTLS is on, it is defence in depth beside the token —
never a replacement for it.