Flaggator.dev

Users, login, and IdP

Create local operators, log in for a JWT, see how roles gate management, and wire an external identity provider. Evaluation stays on SDK tokens.

Virtual environment active, shell at the repository root, process on 127.0.0.1:8080. Flag walkthrough: Checkout rollout. All examples.

01Signing secret and bootstrap

Local login needs FLAGGATOR_JWT_SECRET (at least 32 bytes for HS256). Set it before bootstrap and before starting the process.

source .venv/bin/activate
export FLAGGATOR_JWT_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
python -m main bootstrap

Bootstrap creates flaggator.db when missing, applies migrations, and prints one opaque management token on stdout once. Save it for the first user and for recovery. Prefer JWT users afterward.

export MGMT=the-printed-management-token

In another terminal, with the same secret exported:

export FLAGGATOR_JWT_SECRET=the-same-secret-as-above
python -m main

02Create the first user

Users are global. Creating them needs a writer (or the management token). Roles are a non-empty list from reader, writer, and protected_writer.

curl -s -X POST http://127.0.0.1:8080/v1/users \
  -H "Authorization: Bearer $MGMT" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ops@example.com",
    "name": "Ops",
    "password": "password1",
    "roles": ["reader", "writer", "protected_writer"]
  }'
{
  "id": 1,
  "email": "ops@example.com",
  "name": "Ops",
  "roles": ["reader", "writer", "protected_writer"],
  "created_at": "2026-10-31T15:00:00Z",
  "disabled_at": null
}
no password in the body Email is stored lowercased. Password hashes never appear in API or audit JSON.

03Local login

POST /v1/auth/login needs no bearer. Wrong password, unknown email, disabled user, and IdP-only user (no password) share the same 401.

curl -s -X POST http://127.0.0.1:8080/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ops@example.com","password":"password1"}'
{
  "access_token": "eyJ…",
  "token_type": "bearer",
  "expires_in": 3600
}
export TOKEN='paste-the-access_token-here'

Use TOKEN for management calls. Issuer, audience, and TTL come from FLAGGATOR_JWT_ISSUER, FLAGGATOR_JWT_AUDIENCE, and FLAGGATOR_JWT_TTL_SECONDS (default 3600).

04Manage with the JWT

curl -s -X POST http://127.0.0.1:8080/v1/projects \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"key":"shop","name":"Shop"}'

curl -s http://127.0.0.1:8080/v1/users \
  -H "Authorization: Bearer $TOKEN"
201 then a user page List and get omit passwords. A user JWT cannot evaluate:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8080/v1/evaluate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"project_key":"shop","environment_key":"prod","flag_key":"x","context":{"subject":"a","attributes":{}},"default":false}'
403 Evaluation stays on an SDK token (or the opaque management token for debug).

05Roles in practice

RoleGrants
reader GET projects, environments, flags, targeting, audit, users.
writer All reader rights, plus create/mutate on non-protected resources.
protected_writer Add-on with writer for protected environments. Alone it grants nothing.

Create a reader-only user and log in as them:

curl -s -X POST http://127.0.0.1:8080/v1/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"reader@example.com","name":"Reader","password":"password1","roles":["reader"]}'

curl -s -X POST http://127.0.0.1:8080/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"reader@example.com","password":"password1"}'
export READER='paste-reader-access_token'
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8080/v1/projects \
  -H "Authorization: Bearer $READER" \
  -H "Content-Type: application/json" \
  -d '{"key":"other","name":"Other"}'
403 · A writer role is required.

Create a protected environment with the ops JWT, then try to clear protected with a writer who lacks protected_writer:

curl -s -X POST http://127.0.0.1:8080/v1/projects/shop/environments \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"key":"prod","name":"Production","protected":true}'

curl -s -X POST http://127.0.0.1:8080/v1/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"writer@example.com","name":"Writer","password":"password1","roles":["reader","writer"]}'

curl -s -X POST http://127.0.0.1:8080/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"writer@example.com","password":"password1"}'
export WRITER='paste-writer-access_token'

curl -s -X PATCH http://127.0.0.1:8080/v1/projects/shop/environments/prod \
  -H "Authorization: Bearer $WRITER" \
  -H "Content-Type: application/json" \
  -d '{"protected":false}'
403 · A protected_writer role is required for this environment.

The ops JWT (writer + protected_writer) succeeds:

curl -s -X PATCH http://127.0.0.1:8080/v1/projects/shop/environments/prod \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"protected":false}'
200 · protected false

06IdP-only user (no local password)

Omit password when the person will only authenticate through an external IdP. Local login then returns the same 401 as a bad password.

curl -s -X POST http://127.0.0.1:8080/v1/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"idp@example.com","name":"IdP Ops","roles":["reader","writer"]}'

curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8080/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"idp@example.com","password":"does-not-matter"}'
401 Pre-register the email (or know the numeric local id for sub) before the IdP user can call management APIs.

07External IdP via JWT

One IdP per process. Before start, point flaggator at the provider’s JWKS and at the issuer / audience you expect on tokens:

export FLAGGATOR_JWT_JWKS_URL='https://your-idp.example/.well-known/jwks.json'
export FLAGGATOR_JWT_ISSUER='https://your-idp.example/'
export FLAGGATOR_JWT_AUDIENCE='flaggator'
# keep FLAGGATOR_JWT_SECRET for local HS256 login as well
python -m main

What happens on each request:

  1. Bearer alg is RS* or ES* → verify signature against JWKS, then iss, aud, exp.
  2. Load the local user by decimal sub (local user id) or by normalized email claim.
  3. Roles come from that local row. The IdP is not trusted to invent protected_writer.
  4. No matching enabled user → 401, same shape as an unknown opaque token.

After the IdP issues a token for idp@example.com, call the API with that token as the bearer (no /v1/auth/login exchange):

export IDP_JWT='paste-idp-access-token'

curl -s http://127.0.0.1:8080/v1/projects \
  -H "Authorization: Bearer $IDP_JWT"
200 · project list Same management plane as a local login JWT; roles are still the ones stored on idp@example.com. Detail in Docs · Credentials and the API /v1/users / /v1/auth/login pages.

08Disable a user

curl -s -X PATCH http://127.0.0.1:8080/v1/users/2 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"disabled":true}'

curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8080/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"reader@example.com","password":"password1"}'
401 Email cannot change on patch. Set disabled false to re-enable. Existing JWTs fail on the next request once the user is disabled.