Skip to content

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.

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 carries sub (the user id) and jti (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.
Terminal window
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).

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.

Terminal window
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 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)
  1. Are you sending the right credential? Sending a dashboard JWT to v1/integration/*, or an API token to v1/dashboard/*, is a 401 in both directions, as in the table above. Check whether your Authorization header starts with fd_.
  2. Has the token expired? A dashboard JWT after 60 minutes, a refresh token after 7 days; API tokens can expire through an optional expiresAt.
  3. 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.
  4. 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’s scopes in the dashboard.