Skip to main content

External auth with JWT

Instead of using the built-in authentication and authorization mechanisms, you can generate your own JWT tokens with the desired permissions for your already authenticated users and pass them to Windmill. This way, you control what permissions your users have without having to create them in Windmill.

This feature is Enterprise Edition only.

For billing, a unique external JWT user is counted per (username/email, scope, instance) tuple used in the last 30 days. The same email used with two different scopes, or on two different instances, counts as two JWT users. Each unique JWT user counts as half a Seat - the same as an operator. Operators and external JWT users are interchangeable in seat counting (e.g. 1 operator + 1 external JWT user = 1 Seat).

For JWT verification, the first option is to pass as an environment variable called JWT_EXT_PUBLIC_KEY the public key in PEM format (RS256). The second option is to pass as JWT_EXT_JWKS_URL the url of the JWKs endpoint to retrieve the public keys from which the JWTs will be matched on the kid field. In the latter case, the instance will refresh its cache of public keys every 15 minutes. The environment variable in both cases has to be passed to all servers and workers.

The JWT token should be prefixed with jwt_ext_ to let Windmill know that this is an external token and passed in the Authorization header as a bearer token. The JWT payload has to contain the following fields:

  • username: the username of the user (for logs)
  • email: the email of the user (for logs)
  • is_admin: a boolean indicating if the user is an admin
  • is_operator: a boolean indicating if the user is an operator (run-only)
  • workspace_id: the workspace id. The token will be granted the configured permissions in the specified workspace.
  • workspace_ids: an array of workspace ids. When provided, the token will be granted the configured permissions in all specified workspaces. In that case, the workspace_id field is not required.
  • folders: an array of arrays containing 3 values: the name of the folder, a boolean indicating whether the user can write to the folder and a boolean indicating whether the user is the owner of the folder (can manage folder permissions)
  • groups: an array of strings containing the groups to which the user belongs. When using JWT external authentication, group permissions on folders are not checked because the user's permissions depend directly on the folder permissions specified in the JWT.
  • scopes: an optional array of strings containing the scopes of the user/token. To limit the user's job listing permissions by specific tags, add a scope with the value if_jobs:filter_tags:tag1,tag2.

While you don't need to create the user on Windmill, you should create the workspaces and folders.

Format of the folders and groups claims

Both claims expect bare names, without any path prefix. This is the most common source of "you have no read-access to that item" errors.

For folders, pass only the folder name, not the f/ prefix and not the full path of the item you want to reach. Windmill matches the second segment of an item's path against that list, so to grant read access to the app f/my_folder/apps/my_app, the folder entry is my_folder. Wildcards such as * are not supported, every folder has to be listed explicitly.

For groups, pass the group name without the g/ prefix, which Windmill adds internally. Passing "g/all" is interpreted as the group g/g/all and matches nothing, the correct value is "all".

Here's a payload granting read access to f/my_folder/apps/my_app in the my_workspace workspace:

{
"username": "foo",
"email": "[email protected]",
"is_admin": false,
"is_operator": true,
"workspace_id": "my_workspace",
"folders": [["my_folder", false, false]],
"groups": ["all"]
}

Note that scopes only restricts what a token is allowed to do, it never grants access to an item. A token still needs the matching folders or groups entry, so adding a scope like apps:read:f/my_folder/apps/my_app does not by itself make the app reachable.

Here's an example TypeScript code that generates the JWT token with the required payload and token prefix:

import { createSigner } from 'fast-jwt'

const YOUR_PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----`

async function generateJWT(
kid: string,
username: string,
email: string,
is_admin= false,
is_operator= false,
workspace_id?: string,
workspace_ids?: string[],
folders: [string, boolean, boolean][] = [], // bare folder names, e.g. [['my_folder', false, false]]
groups: string[] = [], // bare group names, e.g. ['all']
scopes: string[] = []
) {
const signer = createSigner({
kid,
algorithm: 'RS256',
key: YOUR_PRIVATE_KEY,
expiresIn: '1h'
})

const token = await signer({
username,
email,
is_admin,
is_operator,
folders,
groups,
workspace_id,
workspace_ids,
scopes
})

return "jwt_ext_" + token
}

Embed public apps using your own authentication

On the Enterprise Edition, it is possible for one to embed public apps and reuse the embedding app auth and userbase. To do so, use an iframe as usual with the public app url as src.

For instance: https://your_instance.com/public/foo/a7f83d827f8bbb3a332c730659f4cf39, but in addition, append to the url the jwt token, separated by a '/'.

https://your_instance.com/public/foo/a7f83d827f8bbb3a332c730659f4cf39/eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IiJ9.ey1c2VybmFtZSI6ImZvbyIsImVtYWlsIjoiZm9vQHdpbmRtaWxsLmRldiIsImlzX2FkbWluIjp0cnVlLCJpc19vcGVyYXRvciI6ZmFsc2UsImZvbGRlcnMiOltdLCJncm91cHMiOltdLCJ3b3Jrc3BhY2VfaWQiOiJmb28iLCJpYXQiOjE3MjM0OTc0ODksImV4cCI6MTcyMzUwMTA4OX0.nnhAGjR_PbuHhLzVCDrTZmRRU9zQd_gpona8bSuvIWlK6Taaxgojn-8tQk1IDykw_WHdnLPgWrOt9uGPAPFD0SsLqK_P-aM1Q8KU-X-Ve3qMQ-Sru5IpE-BgCnb5n0_s2mR6-Ebl6exPYQWJFFNWQ_cj6lDF2fYS71hv2IeQqwssHU4YD1ujUl0rm1rCzRKGuK-iVr9mdFywB2K95iBnhJ0-XLnysjyM4UtutGqLqVZgIHabm2HhgFNNLfHtpfgNYtrk3l3lxCYsr9jmD5Z3lnLcWNhBDWxBlneyIp7yt73hLt7v5QKVoKzFgU_Ikf_FVx0TC7dmUvEKNhqfjfKNA

This will make the app act such that the user authenticated is the one that corresponds to the jwt payload. Windmill uses just-in-time provisioning by default and the user doesn't need to have been pre-provisioned for it to work.