Feature flags
Turn product features on and off per organization without a deploy, from the platform or each organization, and gate them in the UI and in authorization.
A new feature rarely reaches every customer on the same day. You want to try it with a design partner first, roll it out to a quarter of your customers, let each organization opt in to a beta, and switch it off everywhere in seconds when something goes wrong, all without a deploy. Feature flags do that, and because they live in Better IAM next to your organizations, a flag can decide what the UI shows and what authorization allows with the same switch.
Every flag is a boolean, and flags work at two levels:
- Platform flags are defined on the root tenant by root administrators (or anyone the root tenant grants
iam:features:manage). They reach every organization and project. - Organization flags are defined by an organization (or a project) for its own subtree, for example to roll a feature out to some of its projects, or to let each project choose.
Flags are per tenant. To entitle individual people, combine a flag with license seats (see Licenses).
Setup
Feature flags are always on: the features API group and iam.features exist on every instance, and there is no
option to set.
Migrate
Flags, targets, and overrides are stored in the shared iam_records table, which the ordinary migration creates.
npx better-iam migrate --config better-iam.config.mjsCreate the client and the provider
Browsers read flags through the typed client, and React components through hooks rendered inside IamProvider.
import { createIamClient } from 'better-iam/client';
import { IamProvider } from 'better-iam/react';
import type { ReactNode } from 'react';
import type { iam } from '../iam';
const client = createIamClient<typeof iam>({ baseURL: 'https://app.acme.test' });
export function Providers({ children }: { children: ReactNode }) {
return <IamProvider client={client}>{children}</IamProvider>;
}How a value is decided
A flag's value for a tenant comes from the first of four rules that applies:
- Kill switch.
killSwitch: trueturns the flag off everywhere. Targets and overrides stay stored and apply again when the switch is lifted. - The closest target or override. Walking from the tenant up to (but not including) the tenant that defines the
flag, the first tenant with a value decides:
- A target is a value the flag's managers pin for a tenant below them with
features.setTarget, such as "on for Acme until the end of the trial". It applies to that tenant and everything below it, unless a closer target or override decides, and it can lapse (expiresAt). - An override is a tenant's own choice with
features.setOverride, allowed only for flags defined withtenantOverridable: true. On the same tenant, the tenant's override beats an unlocked target. Overrides are refused for internal flags and on the tenant that defines the flag (change the flag itself there withfeatures.update). Withdrawing a choice withvalue: nullis always allowed, even while locked. - A locked target (
locked: true) silences overrides at its tenant and below, so an organization cannot undo it. A closer target from the flag's own managers still wins over it.
- A target is a value the flag's managers pin for a tenant below them with
- Rollout.
rolloutPercentage(0 to 100) turns an off-by-default flag on for a stable share of the branches directly below the defining tenant, and off for the rest. For a platform flag those branches are organizations, so an organization's projects always land on the same side as the organization. Raising the percentage only adds tenants; nobody who had the feature loses it.rolloutBucket(key, tenantId)from@better-iam/servershows where a tenant falls. - Default. When no rollout is set,
defaultValue.
A flag with a rollout must default to off (defaultValue: false), because the rollout is the share that is turned
on. Each evaluation reports its reason: KILL_SWITCH, TARGET, OVERRIDE, ROLLOUT, DEFAULT, or UNKNOWN (no
flag with that key reaches the tenant).
Keys belong to the tenant closest to the root. A tenant cannot define a key an ancestor already defines, and a
platform flag created later takes precedence over an organization flag with the same key (features.list shows the
organization's flag as shadowed). So an organization can never switch a platform-gated feature on for itself. Keys
start with a lowercase letter and join lowercase letters and digits with single -, _, or . characters
(new-billing, reports.v2; not 1st, a--b, or trailing-), up to 64 characters, with at most 200 flags per
tenant.
Usage
Define a flag
features.create (iam:features:manage) defines a flag in the tenant you name; a platform flag is defined on the root
tenant. features.update changes settings (omitted fields are kept, and null clears description or
rolloutPercentage), and features.delete removes the flag with all its targets and overrides, after which code that
asks for it gets false.
const root = { token: rootSessionToken };
// A platform flag that organizations may turn on for themselves.
await iam.api.features.create(root, {
tenantId: rootTenantId,
key: 'new-billing',
description: 'The redesigned billing pages',
tenantOverridable: true,
});
// A gradual rollout to a quarter of all organizations.
await iam.api.features.update(root, { tenantId: rootTenantId, key: 'reports-v2', rolloutPercentage: 25 });Route: POST /api/iam/features/create (also /features/update, /features/delete)
Target a tenant
features.setTarget (iam:features:manage in the defining tenant) pins a value for a tenant below the defining one;
value: null removes it. features.listTargets (iam:features:read in the defining tenant) lists every target and
override below the defining tenant, with active for those that count right now.
// A two-week trial for one organization.
await iam.api.features.setTarget(root, {
tenantId: rootTenantId,
key: 'fast-search',
targetTenantId: acmeId,
value: true,
expiresAt: Date.now() + 14 * 86_400_000,
note: 'Design partner trial',
});
const targets = await iam.api.features.listTargets(root, { tenantId: rootTenantId, key: 'fast-search' });Route: POST /api/iam/features/setTarget (also /features/listTargets)
Target notes are for the flag's managers. An organization sees that a target exists, its value, whether it is locked, and when it ends, but never the note.
Let an organization choose
features.setOverride (iam:features:override, authorized in the tenant making the choice) records a tenant's own
value for a flag defined with tenantOverridable: true. It answers FEATURE_LOCKED when the flag does not allow
overrides or a locked target covers the tenant.
// Acme's administrators opt in to the new billing pages.
await iam.api.features.setOverride(acmeAdmin, { tenantId: acmeId, key: 'new-billing', value: true });
// And return to the default later.
await iam.api.features.setOverride(acmeAdmin, { tenantId: acmeId, key: 'new-billing', value: null });Route: POST /api/iam/features/setOverride
Turn a feature off everywhere
The kill switch is a flag setting, so lifting it restores every target and override.
await iam.api.features.update(root, { tenantId: rootTenantId, key: 'fast-search', killSwitch: true });Route: POST /api/iam/features/update
List the flags that reach a tenant
features.list (iam:features:read) returns the flags that reach the tenant, each with its evaluation, the tenant's
override, and its target, plus the tenant's own flags that an ancestor shadowed.
const { flags, shadowed } = await iam.api.features.list(acmeAdmin, { tenantId: acmeId });Route: POST /api/iam/features/list
Read flags in your application
On the server, deployment code reads flags without a credential, internal flags included:
import { iam } from './iam';
if (await iam.features.isEnabled(tenantId, 'new-billing')) showNewBilling();
const values = await iam.features.values(tenantId); // { 'new-billing': true, ... }
const [detail] = await iam.features.evaluate(tenantId, { keys: ['new-billing'] });
// { key, value, reason: 'OVERRIDE', scope: 'platform', definedBy, decidedBy, locked, overridable }From a browser or another service, features.evaluate needs only a credential of the tenant: a user session, an API
key, a role session, or a session token whose tenant it is (root administrators may evaluate any tenant). It is not
audited and leaves out ancestors' internal flags:
const { flags } = await iam.api.features.evaluate({ headers: request.headers }, { tenantId });Route: POST /api/iam/features/evaluate
The hooks return false while loading, when signed out, and for unknown keys. Hiding a button is not enforcement, so
gate the server side as well, in code or in a policy.
Internal flags
Internal flags (internal: true) are for trusted server code and policies only. They are hidden from
features.evaluate and from the features.list of tenants below the defining one (root administrators still see
them). Targets can reach them, but tenants cannot override them, so tenantOverridable is refused for them.
Each flag is the resource iam/features/{key} (features.list uses iam/features), so a policy can delegate one
flag: for example iam:features:override on iam/features/new-* for a product team. Every change is audited
(feature:create, feature:update with the settings before and after, feature:delete, feature:target,
feature:override) and reaches webhooks like any audit event.
Flags in policies
Decisions see the keys of every flag that is on for the decision's tenant, internal flags included, as the list
tenant.features. A policy can then allow an action only where a feature is on:
{
"effect": "allow",
"actions": ["documents:export"],
"resources": ["*"],
"conditions": { "ArrayContains": { "tenant.features": "exports" } }
}The server reads flags only for decisions whose documents name tenant.features, so policies that do not use flags
pay nothing. Applications cannot supply the key through resolveContext or plugins, because the server removes it.
Policy lint treats it as a list, so test it with ArrayContains, not string operators.
policies.test fills it with the tenant's current flags when the candidate
document names it, and a tenant.features value you pass in the test's context takes precedence.
Options
There is no server option for feature flags. Each flag carries these settings (features.create, features.update):
| Option | Type | Default | Description |
|---|---|---|---|
key | string | required | Lowercase flag key, up to 64 characters. Never changes. |
description | string | none | Up to 512 characters; null clears it on update. |
defaultValue | boolean | false | The value when no target, override, or rollout decides. |
rolloutPercentage | number | none | 0 to 100; requires defaultValue: false; null clears it on update. |
tenantOverridable | boolean | false | Tenants below may choose their own value. Refused for internal flags. |
killSwitch | boolean | false | Off everywhere, whatever targets and overrides say. |
internal | boolean | false | Only server code and policies see the flag. |
Targets (features.setTarget) take targetTenantId, value (null removes the target), locked, expiresAt, and
note (up to 512 characters).
Permissions
| Action | Where | Methods |
|---|---|---|
iam:features:manage | The defining tenant | create, update, delete, setTarget |
iam:features:read | The tenant read (listTargets: the defining tenant) | list, listTargets |
iam:features:override | The tenant making the choice | setOverride |
| None | Any credential of the tenant | evaluate |
Schema
Records live in the iam_records table under two tenant-scoped collections. A target or override lives in the tenant it
applies to, so purging that tenant removes it; deleting the flag removes all of them.
| Collection | Holds | Key fields |
|---|---|---|
featureFlags | A flag the tenant defines | key, description, defaultValue, rolloutPercentage, tenantOverridable, killSwitch, internal, createdAt, createdBy, updatedAt, updatedBy |
featureTargets | A target or override for one tenant and its descendants | flagId, key, definerId (the defining tenant), source (target or override), value, locked, expiresAt, note, setAt, setBy |
Errors
| Code | Status | When |
|---|---|---|
FEATURE_LOCKED | 409 | setOverride on a flag that does not let tenants choose, or under a locked target |
CONFLICT | 409 | The key exists in the tenant, or an ancestor (such as the platform) already defines it |
LIMIT_EXCEEDED | 409 | More than 200 flags in a tenant |
NOT_FOUND | 404 | An unknown flag, or an internal flag of another tenant |
ACCESS_DENIED | 403 | Missing permission, or evaluate with a credential of another tenant |
INVALID_INPUT | 400 | A malformed key, a rollout with defaultValue: true, an override on the defining tenant, a target for the defining tenant itself or a tenant outside its subtree, or tenantOverridable on an internal flag |
INVALID_TRANSITION | 409 | Changing flags of a deleted tenant |
In the console
- Administration → Feature flags (root): platform flags with their settings and kill switch, every target and organization choice, a form to target an organization or project (optionally locked or lapsing), and new flags.
- Organization → Features: the platform's flags as they apply to the organization, with Turn on, Turn off, and Use default where the platform allows a choice, plus the organization's own flags with per-project targets.
Next steps
Better IAM is created by Sean Filimon
Last updated