Authentication
FlowDesk has two separate authentication surfaces, and the separation is deliberate rather than accidental:
| Credential | How you get it | Where it is valid |
|---|---|---|
| Dashboard JWT | POST /v1/dashboard/auth/login (or the LDAP/Keycloak/OTP variants) |
Only v1/dashboard/* |
API token (fd_ prefix) |
Created in the dashboard by an administrator with POST /v1/dashboard/api-tokens |
Only v1/integration/* |
The Quick start page walks both steps end to end: signing in to the dashboard, creating a token, opening the first request. This page explains why the flow works that way, how sessions behave, and what to do when you get a 401.
The v1/external/* endpoints (request tracking, embedded forms) are a third surface, separate
from both and requiring no authentication; see Tracking requests.
The dashboard JWT
Section titled “The dashboard JWT”v1/dashboard/auth/login (and the other login variants) returns a JWT pair signed with RS256:
{ "success": true, "data": { "accessToken": "eyJhbGciOiJSUzI1NiIs...", "accessTokenExpiresAt": "2026-08-13T10:15:00Z", "refreshToken": "8f2c1a...", "refreshTokenExpiresAt": "2026-08-20T10:00:00Z" }}accessToken: valid for 60 minutes by default (JWT:AccessTokenExpireMinutes). It carriessub(the user id) andjti(an identity specific to this token); the signature is verified with the public key matching the private one.refreshToken: an opaque, randomly generated string, valid for 7 days. It is stored server side (it is not a JWT), so on its own it carries no meaningful data.
Refresh and rotation
Section titled “Refresh and rotation”curl -s -X POST https://api.example.com/v1/dashboard/auth/refresh-token \ -H "Content-Type: application/json" \ -d '{ "refreshToken": "8f2c1a..." }'A successful response has the same shape as the login response: a new accessToken and a new
refreshToken. The refreshToken you used is invalidated immediately by that call. The old
one is deleted before the new pair is issued. In other words each refresh token can be used
exactly once; sending the same value a second time fails (400).
Single active session
Section titled “Single active session”When a new refresh token is issued for a user (by a new login or by a refresh call), that user’s previous refresh token is invalidated immediately. That means one active session per user and platform: sign in with the same account from a second place and the first session’s refresh token quietly dies.
The practical consequence: the first session’s access token keeps working until its own 60 minutes are up (JWT validation does not consult a session table server side). The first moment you notice the difference is when the first session’s access token expires and you try to refresh it: at that point the refresh token is already invalid and you have to sign in again.
Logout
Section titled “Logout”curl -s -X POST https://api.example.com/v1/dashboard/auth/logout \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."Logout does two things at once: that access token’s jti is blacklisted for its remaining
lifetime (which is why logging an already-expired token costs nothing), and the active refresh
token for that user and platform is revoked. Any request arriving with a blacklisted jti is
rejected even when its signature is valid: this is the mechanism that actually makes a logged-out
access token unusable.
The API token
Section titled “The API token”The integration endpoints (v1/integration/*) do not recognise a dashboard JWT at all. They work
with an API token carrying the fd_ prefix, created in the dashboard by an administrator; for
the creation flow and the pool scope rules see
Quick start → step 2 and
Creating requests.
In short: the scopes the token carries (tickets:read, tickets:write) decide which endpoints
you can call, and poolIds/allPools decide which pools you can open and read requests in. A
token can carry an optional expiresAt; an expired token returns 401.
Why a dashboard JWT returns 401 on integration endpoints
Section titled “Why a dashboard JWT returns 401 on integration endpoints”This is the most important point on this page, and it is behaviour implemented in real code, not a contract:
Every endpoint under v1/integration/* binds its authorisation explicitly to the ApiToken
scheme ([Authorize(AuthenticationSchemes = "ApiToken", Policy = ...)]). The handler that
validates API tokens treats the value in the Authorization header as its own only when it
starts with fd_; when it does not (a dashboard JWT, for example) it does not get involved in
that request at all: it says “not my job” and leaves the result empty.
The catch is that the authorisation policy on those endpoints listens only to the ApiToken
scheme. The separate scheme that validates dashboard JWTs never runs, because this policy never
calls it. The result: even if you send a valid, unexpired dashboard JWT, v1/integration/*
behaves as if it never saw it — the request counts as unauthenticated and returns 401. The
reverse works the same way: sending an API token to v1/dashboard/* is not validated either.
| Request | v1/dashboard/* |
v1/integration/* |
|---|---|---|
Dashboard JWT (Bearer eyJ...) |
Works | 401 |
API token (Bearer fd_...) |
401 | Works (if the scope is sufficient) |
Checklist when you get a 401
Section titled “Checklist when you get a 401”- Are you sending the right credential? Sending a dashboard JWT to
v1/integration/*, or an API token tov1/dashboard/*, is a 401 in both directions, as in the table above. Check whether yourAuthorizationheader starts withfd_. - Has the token expired? A dashboard JWT after 60 minutes, a refresh token after 7 days; API
tokens can expire through an optional
expiresAt. - Was the token revoked? API token validation is cached for up to 30 seconds: a token revoked from the dashboard a moment ago can still appear to work during that window. That is expected behaviour, not a bug.
- Did you get a 401 or a 403? They say different things: 401 means your credential is
invalid (missing, malformed, expired, revoked), while 403 means your credential is valid but
insufficiently scoped for this operation (trying to open a request with a token that only
has
tickets:read, for example). On a 403, check the token’sscopesin the dashboard.
What’s next
Section titled “What’s next”- Everything about opening a request with an API token: Creating requests
- Letting the end user follow a request without authenticating after it is opened: Tracking requests
- The full per-endpoint contract: API Reference