Skip to content

Authentication

Flaghoist has two authenticated paths, each with a pluggable verifier.

  • Read path: apps evaluating flags. Guarded by an API key.
  • Admin path: creating, changing and deleting flags. Guarded by the admin token, by people’s own accounts once you turn them on, or by JWTs from your identity provider.

This page covers the credentials. For accounts, roles, invites, single sign-on and two-factor codes, see Accounts and team access.

createFlagServer((env) => ({
storage,
auth: {
read: apiKey(env.READ_API_KEY),
admin: bearerToken(env.ADMIN_TOKEN),
},
}))
read: apiKey(env.READ_API_KEY)

Clients send it as x-api-key. The comparison is constant-time. The read path only ever returns evaluated booleans, never flag configuration, so this key gates drive-by scraping, not secrecy (it ships in your browser bundle).

admin: bearerToken(env.ADMIN_TOKEN)

Clients send Authorization: Bearer <token>. Possession of the token is admin authorization: the simplest self-host default.

Generate a strong one and never a memorable one. Anyone who guesses or steals it has full admin access, and Flaghoist does not rate limit authentication unless you turn it on:

Terminal window
openssl rand -hex 32

Changing the token on Cloudflare Workers is one command, and it updates the live secret without a redeploy:

Terminal window
npx wrangler secret put ADMIN_TOKEN

On a container host, change the ADMIN_TOKEN environment variable and restart instead.

Rotation is a hard cutover: the moment the secret changes, every client still holding the old token is rejected until you update it. With one holder (you) that costs nothing. Once a team shares the token, turn on accounts: each person gets their own sign-in, role and access tokens, and the admin token is kept for recovery. It has no expiry and no second factor by design; it is the zero-config default, not the end state.

Validate real JWTs from Cognito, Auth0, Okta, Keycloak, or Entra, with group-based authorization:

import { oidc } from '@flaghoist/server'
admin: oidc({
issuer: env.OIDC_ISSUER,
audience: env.OIDC_AUDIENCE,
groupsClaim: 'cognito:groups',
allowedGroups: ['ADMIN', 'SUPER_ADMIN'],
tokenUse: 'id', // Cognito id tokens
})

This validates the signature against the provider’s JWKS with a pinned algorithm allowlist (RS256 by default; alg: none is always rejected), checks iss / aud / exp / nbf, and then requires membership in an allowed group. A valid token that lacks an admin group is rejected with 403.

For the exact config for each provider (issuer format, which claim carries groups, and where to find the values in their console) see OIDC provider setup.

This verifier is for API clients that already hold a JWT from your provider. For people signing in to the dashboard with that provider, use single sign-on instead.

Every admin route checks a role: viewer, editor, admin or owner. The table of what each can do is on Accounts and team access. With accounts on, each person’s role comes from their account. Without them, a verifier can say which role its caller has.

A verifier that returns no role grants owner, the full access every admin verifier had before roles existed. bearerToken and oidc return no role, so they behave as they always have. A role that is not one of the four grants nothing.

This gives a second, read-only token to someone who should see the dashboard but not change anything:

const owner = bearerToken(env.ADMIN_TOKEN)
const readOnly = bearerToken(env.VIEWER_TOKEN)
admin: async (headers) => {
const asOwner = await owner(headers)
if (asOwner.ok) return asOwner
const asViewer = await readOnly(headers)
return asViewer.ok ? { ok: true, identity: 'viewer', role: 'viewer' } : asViewer
}

A request the caller’s role does not cover is refused with 403 and { "error": "This needs the editor role or higher.", "code": "insufficient_role" }. The code tells it apart from a 403 that rejects the credential itself, and the dashboard uses it to show the message instead of signing you out.

  • A release flag is not an authorization boundary: it controls whether a code path is visible, not whether a user is allowed. Keep authorization in your backend.
  • Targeting context is attacker-controlled. Every attribute in an evaluation request is supplied by the caller and can be anything they choose. If a paywall flag targets plan == "pro" on the client-supplied plan, a caller who sends plan: "pro" is served the feature. Never gate anything that matters on a self-asserted attribute. When a targeting decision must be trustworthy, derive the attribute from a validated session or header and inject it through trustedContext, which overrides whatever the client sent.
  • Set an exact-origin CORS allowlist (allowedOrigins) for the admin dashboard.

See the threat model for the full picture.