A flag is not a permission
Feature flags and permissions answer different questions. A flag asks whether a capability is available for a context or rollout. A permission asks whether the authenticated user is allowed to perform the operation. A robust guard needs both answers.
Client-side checks improve the interface by hiding or disabling unavailable actions. They are never a security boundary: browser code and client-visible flag values can be inspected or bypassed. The server must repeat the decision with trusted identity data before performing protected work.
Centralize flag names
Scattered string literals are easy to mistype and difficult to retire. Keep a small typed catalog in each application, use the same serialized keys, and give every evaluation a conservative fallback. A missing or unavailable flag should fail closed for a guarded capability.
// frontend/flags.ts
export const FeatureFlag = {
ExportReports: 'export-reports',
} as const;
export type FeatureFlag =
(typeof FeatureFlag)[keyof typeof FeatureFlag];Compose the React guard
The frontend guard combines the application’s trusted session model with LaunchDarkly’s current flag value. With the React Web SDK, a typed single-flag hook subscribes only to the flag it evaluates. Keep the hook generic so individual components do not reimplement the same boolean expression.
Use the result to shape the interface, not to claim the operation is authorized. A disabled button can explain why an action is unavailable; omitting the control entirely can be appropriate when discovery would only create confusion.
import { useBoolVariation } from '@launchdarkly/react-sdk';
type Permission = 'reports:export' | 'reports:read';
export function useCapability(
permission: Permission,
flagKey: string,
) {
const { permissions } = useSession();
const flagEnabled = useBoolVariation(flagKey, false);
return permissions.includes(permission) && flagEnabled;
}
function ExportButton() {
const canExport = useCapability(
'reports:export',
FeatureFlag.ExportReports,
);
if (!canExport) return null;
return <button>Export report</button>;
}Enforce the same rule in FastAPI
The backend guard should use the server-side SDK and identity reconstructed from the authenticated request. Initialize one shared LaunchDarkly client for the process rather than creating a client per request. Evaluate a context containing only the attributes your targeting rules require.
Returning 403 is appropriate when the user lacks a known permission. When a feature is unavailable for rollout reasons, some APIs choose 404 to avoid advertising an inactive capability; choose one policy and apply it consistently.
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from ldclient import Context, LDClient
def require_capability(
permission: str,
flag_key: str,
) -> Callable:
async def guard(
user: User = Depends(current_user),
flags: LDClient = Depends(get_flag_client),
) -> User:
if permission not in user.permissions:
raise HTTPException(status.HTTP_403_FORBIDDEN)
context = (
Context.builder(str(user.id))
.set("organization", str(user.organization_id))
.build()
)
if not flags.variation(flag_key, context, False):
raise HTTPException(status.HTTP_404_NOT_FOUND)
return user
return guard
@router.post("/reports/export")
async def export_report(
user: User = Depends(require_capability(
"reports:export",
"export-reports",
)),
):
return await create_export(user)Test the decision matrix
The combined guard has four meaningful states: permission and flag on, permission only, flag only, and neither. Test all four at the reusable boundary. Component and endpoint tests can then focus on behavior rather than rebuilding the matrix for every feature.
Also test the fallback path. SDK initialization problems and unknown flag keys should not accidentally grant access. For protected capabilities, false is the useful default.
it.each([
{ hasPermission: true, flagEnabled: true, allowed: true },
{ hasPermission: true, flagEnabled: false, allowed: false },
{ hasPermission: false, flagEnabled: true, allowed: false },
{ hasPermission: false, flagEnabled: false, allowed: false },
])('evaluates both inputs', testCase => {
expect(canUseCapability(testCase)).toBe(testCase.allowed);
});Retire the rollout, keep the permission
Feature flags should usually be temporary. Once a rollout is complete and stable, remove the flag branches and their tests. The underlying permission often remains because authorization is part of the product’s durable access model.
Keeping those lifecycles separate prevents a temporary release control from becoming permanent security architecture.