LaunchDarkly Hardening Guide
Feature flag security for SDK keys, environment access, and approval workflows
Overview
LaunchDarkly manages feature flags controlling application behavior across environments. REST API, SDK keys, and webhook integrations control feature rollouts. Compromised access enables feature manipulation, environment privilege escalation, or extraction of targeting rules revealing business logic.
Intended Audience
- Security engineers managing feature flag systems
- DevOps/Platform administrators
- GRC professionals assessing release management
- Third-party risk managers evaluating deployment integrations
How to Use This Guide
- L1 (Baseline): Essential controls for all organizations
- L2 (Hardened): Enhanced controls for security-sensitive environments
- L3 (Maximum Security): Strictest controls for regulated industries
Scope
This guide covers LaunchDarkly security configurations including authentication, access controls, and integration security.
Table of Contents
1. Authentication & Access Controls
1.1 Enforce SSO with MFA
Profile Level: L1 (Baseline) NIST 800-53: IA-2(1)
ClickOps Implementation
Step 1: Configure SAML SSO
- Navigate to: Account settings → Security → SAML
- Configure SAML IdP
- Enable: Require SSO
Step 2: Configure SCIM
- Enable SCIM provisioning
- Configure user/group sync
- Set deprovisioning behavior
Code Pack: API Script
# Audit all members for MFA enrollment
MEMBERS=$(ld_get "/members?limit=100") || {
fail "1.1 Unable to retrieve member list"
increment_failed; summary; exit 0
}
TOTAL=$(echo "${MEMBERS}" | jq '.totalCount')
NO_MFA=$(echo "${MEMBERS}" | jq '[.items[] | select(.mfa == false)] | length')
info "1.1 Total members: ${TOTAL}, without MFA: ${NO_MFA}"
if [ "${NO_MFA}" -gt 0 ]; then
warn "1.1 Members without MFA:"
echo "${MEMBERS}" | jq -r '.items[] | select(.mfa == false) | " - \(.email) (role: \(.role))"'
fail "1.1 ${NO_MFA} member(s) do not have MFA enabled"
increment_failed
else
pass "1.1 All members have MFA enabled"
increment_applied
fi
1.2 Role-Based Access Control
Profile Level: L1 (Baseline) NIST 800-53: AC-3, AC-6
ClickOps Implementation
Step 1: Define Custom Roles
| Role | Permissions |
|---|---|
| Admin | Full access |
| Writer | Create/modify flags |
| Reader | View only |
| No access | Blocked |
Step 2: Configure Project/Environment Access
- Navigate to: Account settings → Roles
- Create environment-specific roles
- Apply least privilege
Code Pack: Terraform
# Production read-only role — least-privilege RBAC
resource "launchdarkly_custom_role" "prod_readonly" {
key = "hth-prod-readonly"
name = "HTH Production Read-Only"
description = "View production flags without modification rights"
base_permissions = "no_access"
policy_statements {
effect = "allow"
actions = ["viewProject"]
resources = ["proj/*"]
}
policy_statements {
effect = "deny"
actions = ["updateOn", "updateOff", "updateRules", "updateTargets", "updateFallthrough"]
resources = ["proj/*:env/production:flag/*"]
}
}
# Staging deployer role — write flags in staging only
resource "launchdarkly_custom_role" "staging_deployer" {
key = "hth-staging-deployer"
name = "HTH Staging Deployer"
description = "Manage flags in staging, read-only in production"
base_permissions = "no_access"
policy_statements {
effect = "allow"
actions = ["viewProject"]
resources = ["proj/*"]
}
policy_statements {
effect = "allow"
actions = ["*"]
resources = ["proj/*:env/staging:flag/*"]
}
policy_statements {
effect = "deny"
actions = ["*"]
resources = ["proj/*:env/production:flag/*"]
}
}
# Scoped service token for CI/CD
resource "launchdarkly_access_token" "cicd" {
name = "HTH CI/CD Pipeline"
service_token = true
inline_roles {
effect = "allow"
actions = ["updateOn", "updateOff"]
resources = ["proj/${var.project_key}:env/staging:flag/*"]
}
inline_roles {
effect = "deny"
actions = ["*"]
resources = ["proj/${var.project_key}:env/production:flag/*"]
}
}
Code Pack: API Script
# Create a production read-only custom role
EXISTING=$(ld_get "/roles" | jq -r '.items[].key') || true
if echo "${EXISTING}" | grep -q "^hth-prod-readonly$"; then
info "1.2 Role 'hth-prod-readonly' already exists"
else
info "1.2 Creating 'hth-prod-readonly' custom role..."
RESPONSE=$(ld_post "/roles" '{
"key": "hth-prod-readonly",
"name": "HTH Production Read-Only",
"description": "Read-only access to production environment",
"basePermissions": "no_access",
"policy": [
{
"effect": "allow",
"actions": ["viewProject"],
"resources": ["proj/*"]
},
{
"effect": "deny",
"actions": ["updateOn", "updateOff", "updateRules", "updateTargets", "updateFallthrough"],
"resources": ["proj/*:env/production:flag/*"]
}
]
}') || {
fail "1.2 Failed to create custom role"
increment_failed; summary; exit 0
}
pass "1.2 Custom role 'hth-prod-readonly' created"
fi
Code Pack: Sigma Detection Rule
detection:
selection:
kind: 'member'
accesses.action: 'updateMemberRole'
filter_admin:
title|contains: 'admin'
condition: selection and filter_admin
fields:
- date
- title
- member.email
- subject.name
- accesses.resource
2. SDK & API Security
2.1 Secure SDK Keys
Profile Level: L1 (Baseline) NIST 800-53: IA-5
Description
Protect LaunchDarkly SDK keys.
Rationale
Attack Scenario: Exposed SDK key enables flag enumeration; mobile SDK key in client bundle allows targeting rule extraction.
Implementation
SDK Key Types:
| Key Type | Exposure Risk | Use Case |
|---|---|---|
| SDK Key | Server-side only | Backend services |
| Mobile Key | Client-side safe | Mobile apps |
| Client-side ID | Client-side safe | Browser apps |
Step 1: Rotate Keys
- Navigate to: Project settings → Environments
- Reset SDK keys periodically
- Update applications
Code Pack: API Script
# Check secure mode on all environments
ENVIRONMENTS=$(ld_get "/projects/${LD_PROJECT_KEY}/environments") || {
fail "2.1 Unable to retrieve environments"
increment_failed; summary; exit 0
}
ENVS_WITHOUT_SECURE=$(echo "${ENVIRONMENTS}" | jq '[.items[] | select(.secureMode == false)] | length')
TOTAL_ENVS=$(echo "${ENVIRONMENTS}" | jq '.items | length')
info "2.1 Total environments: ${TOTAL_ENVS}"
if [ "${ENVS_WITHOUT_SECURE}" -gt 0 ]; then
warn "2.1 Environments without secure mode:"
echo "${ENVIRONMENTS}" | jq -r '.items[] | select(.secureMode == false) | " - \(.key)"'
fi
# Enable secure mode on production environment
PROD_SECURE=$(echo "${ENVIRONMENTS}" | jq -r '.items[] | select(.key == "production") | .secureMode')
if [ "${PROD_SECURE}" = "true" ]; then
pass "2.1 Production environment has secure mode enabled"
increment_applied
else
info "2.1 Enabling secure mode on production..."
ld_semantic_patch "/projects/${LD_PROJECT_KEY}/environments/production" '{
"comment": "HTH: Enable secure mode to prevent client-side SDK user impersonation",
"instructions": [
{"kind": "updateSecureMode", "value": true}
]
}' || {
fail "2.1 Failed to enable secure mode on production"
increment_failed; summary; exit 0
}
pass "2.1 Secure mode enabled on production"
increment_applied
fi
2.2 API Token Security
Profile Level: L1 (Baseline) NIST 800-53: IA-5
ClickOps Implementation
Step 1: Audit Access Tokens
- Navigate to: Account settings → Authorization → Access tokens
- Review all tokens
- Remove unused tokens
Step 2: Create Scoped Tokens
- Create tokens with custom roles
- Limit to specific projects/environments
- Set expiration dates
Code Pack: Terraform
# Scoped read-only token for monitoring
resource "launchdarkly_access_token" "monitoring" {
name = "HTH Monitoring Read-Only"
service_token = true
inline_roles {
effect = "allow"
actions = ["viewProject"]
resources = ["proj/*"]
}
inline_roles {
effect = "deny"
actions = ["createFlag", "deleteFlag", "updateOn", "updateOff"]
resources = ["proj/*:env/*:flag/*"]
}
}
# Scoped token for a specific project/environment
resource "launchdarkly_access_token" "project_scoped" {
name = "HTH Project-Scoped Token"
service_token = true
inline_roles {
effect = "allow"
actions = ["viewProject", "updateOn", "updateOff"]
resources = ["proj/${var.project_key}:env/${var.environment_key}:flag/*"]
}
}
Code Pack: API Script
# List all tokens and check for overly permissive ones
TOKENS=$(ld_get "/tokens?showAll=true") || {
fail "2.2 Unable to retrieve access tokens"
increment_failed; summary; exit 0
}
TOTAL_TOKENS=$(echo "${TOKENS}" | jq '.items | length')
ADMIN_TOKENS=$(echo "${TOKENS}" | jq '[.items[] | select(.role == "admin")] | length')
NO_ROLE_TOKENS=$(echo "${TOKENS}" | jq '[.items[] | select(.role == "writer" or .role == "admin") | select(.serviceToken == true)] | length')
info "2.2 Total tokens: ${TOTAL_TOKENS}"
info "2.2 Admin-level tokens: ${ADMIN_TOKENS}"
info "2.2 Service tokens with write/admin: ${NO_ROLE_TOKENS}"
if [ "${ADMIN_TOKENS}" -gt 0 ]; then
warn "2.2 Tokens with admin role (should use scoped roles instead):"
echo "${TOKENS}" | jq -r '.items[] | select(.role == "admin") | " - \(.name // "unnamed") (id: \(._id))"'
fail "2.2 ${ADMIN_TOKENS} token(s) have admin role — scope down with custom roles"
increment_failed
else
pass "2.2 No tokens with admin role"
increment_applied
fi
Code Pack: Sigma Detection Rule
detection:
selection:
kind: 'token'
accesses.action: 'createToken'
condition: selection
fields:
- date
- title
- member.email
- target.name
- accesses.resource
3. Environment Security
3.1 Environment Segmentation
Profile Level: L1 (Baseline) NIST 800-53: CM-3
ClickOps Implementation
Step 1: Configure Environment Settings
- Navigate to: Project settings → Environments
- Configure:
- Require comments for changes
- Require review for production
- Enable change history
Step 2: Approval Workflows (Enterprise)
- Configure approval requirements
- Set minimum approvers
- Define bypass conditions
Code Pack: Terraform
# Hardened production environment with approval workflows
resource "launchdarkly_environment" "production" {
key = "production"
name = "Production"
color = "FF0000"
project_key = var.project_key
require_comments = true
confirm_changes = true
secure_mode = true
critical = true
default_track_events = true
approval_settings {
required = true
min_num_approvals = 2
can_review_own_request = false
can_apply_declined_changes = false
required_approval_tags = ["sensitive"]
service_kind = "launchdarkly"
}
tags = ["hth-hardened"]
}
# Staging environment — comments required, but no approval gate
resource "launchdarkly_environment" "staging" {
key = "staging"
name = "Staging"
color = "FFA500"
project_key = var.project_key
require_comments = true
confirm_changes = true
secure_mode = false
critical = false
}
Code Pack: API Script
# Enable require-comments, confirm-changes, and mark as critical
CURRENT=$(ld_get "/projects/${LD_PROJECT_KEY}/environments/production") || {
fail "3.1 Unable to retrieve production environment"
increment_failed; summary; exit 0
}
REQUIRE_COMMENTS=$(echo "${CURRENT}" | jq -r '.requireComments')
CONFIRM_CHANGES=$(echo "${CURRENT}" | jq -r '.confirmChanges')
IS_CRITICAL=$(echo "${CURRENT}" | jq -r '.critical')
INSTRUCTIONS="[]"
if [ "${REQUIRE_COMMENTS}" != "true" ]; then
INSTRUCTIONS=$(echo "${INSTRUCTIONS}" | jq '. + [{"kind": "updateRequireComments", "value": true}]')
fi
if [ "${CONFIRM_CHANGES}" != "true" ]; then
INSTRUCTIONS=$(echo "${INSTRUCTIONS}" | jq '. + [{"kind": "updateConfirmChanges", "value": true}]')
fi
if [ "${IS_CRITICAL}" != "true" ]; then
INSTRUCTIONS=$(echo "${INSTRUCTIONS}" | jq '. + [{"kind": "updateCritical", "value": true}]')
fi
if [ "$(echo "${INSTRUCTIONS}" | jq 'length')" -gt 0 ]; then
info "3.1 Applying environment hardening..."
PAYLOAD=$(jq -n --argjson inst "${INSTRUCTIONS}" '{
"comment": "HTH: Harden production environment controls",
"instructions": $inst
}')
ld_semantic_patch "/projects/${LD_PROJECT_KEY}/environments/production" "${PAYLOAD}" || {
fail "3.1 Failed to update production environment"
increment_failed; summary; exit 0
}
pass "3.1 Production environment hardened"
increment_applied
else
pass "3.1 Production environment already hardened"
increment_applied
fi
Code Pack: Sigma Detection Rule
detection:
selection:
kind: 'environment'
accesses.action:
- 'updateRequireComments'
- 'updateConfirmChanges'
- 'updateSecureMode'
- 'updateApprovalSettings'
filter_production:
accesses.resource|contains: 'env/production'
condition: selection and filter_production
fields:
- date
- title
- member.email
- accesses.action
- accesses.resource
- previousVersion
- currentVersion
3.2 Flag Security
Profile Level: L2 (Hardened) NIST 800-53: CM-7
Implementation
Step 1: Tag Sensitive Flags
- Tag flags controlling security features
- Apply additional review requirements
- Audit changes
Step 2: Targeting Rule Protection
- Limit who can view targeting rules
- Audit rule changes
- Monitor for enumeration
Code Pack: Terraform
# Example: Secure feature flag with restricted client-side exposure
resource "launchdarkly_feature_flag" "secure_flag_example" {
project_key = var.project_key
key = "hth-example-secure-flag"
name = "HTH Example Secure Flag"
description = "Demonstrates HTH flag security best practices"
variation_type = "boolean"
temporary = true
# Restrict client-side SDK exposure
client_side_availability {
using_environment_id = false
using_mobile_key = false
}
# Assign a team maintainer for lifecycle ownership
maintainer_team_key = var.maintainer_team_key
tags = ["sensitive", "hth-managed"]
variations {
value = true
name = "Enabled"
description = "Feature is active"
}
variations {
value = false
name = "Disabled"
description = "Feature is inactive"
}
defaults {
on_variation = 0
off_variation = 1
}
}
Code Pack: API Script
# Check for stale flags and unrestricted client-side exposure
FLAGS=$(ld_get "/flags/${LD_PROJECT_KEY}?summary=true&limit=100") || {
fail "3.2 Unable to retrieve flags"
increment_failed; summary; exit 0
}
TOTAL_FLAGS=$(echo "${FLAGS}" | jq '.items | length')
TEMP_FLAGS=$(echo "${FLAGS}" | jq '[.items[] | select(.temporary == true)] | length')
CLIENT_EXPOSED=$(echo "${FLAGS}" | jq '[.items[] | select(.clientSideAvailability.usingEnvironmentId == true)] | length')
NO_MAINTAINER=$(echo "${FLAGS}" | jq '[.items[] | select(._maintainer == null and ._maintainerTeam == null)] | length')
info "3.2 Total flags: ${TOTAL_FLAGS}"
info "3.2 Temporary flags: ${TEMP_FLAGS}"
info "3.2 Client-side exposed: ${CLIENT_EXPOSED}"
info "3.2 Without maintainer: ${NO_MAINTAINER}"
if [ "${NO_MAINTAINER}" -gt 0 ]; then
warn "3.2 ${NO_MAINTAINER} flag(s) have no assigned maintainer"
fi
if [ "${CLIENT_EXPOSED}" -gt 0 ]; then
warn "3.2 ${CLIENT_EXPOSED} flag(s) exposed to client-side SDKs — review for sensitive data"
fi
pass "3.2 Flag security audit complete"
increment_applied
4. Monitoring & Detection
4.1 Audit Log
Profile Level: L1 (Baseline) NIST 800-53: AU-2, AU-3
ClickOps Implementation
Step 1: Access Audit Log
- Navigate to: Account settings → Audit log
- Review changes
- Configure SIEM export
Detection Focus
Code Pack: Terraform
# Splunk audit log subscription
resource "launchdarkly_audit_log_subscription" "splunk" {
integration_key = "splunk"
name = "HTH Splunk Audit Stream"
on = true
config = {
base_url = var.splunk_hec_url
token = var.splunk_hec_token
}
statements {
effect = "allow"
actions = ["*"]
resources = ["proj/*"]
}
tags = ["hth", "siem"]
}
# Signed webhook for custom SIEM
resource "launchdarkly_webhook" "siem" {
name = "HTH SIEM Webhook"
url = var.siem_webhook_url
on = true
secret = var.webhook_signing_secret
statements {
effect = "allow"
actions = ["*"]
resources = ["proj/*"]
}
tags = ["hth", "siem"]
}
Code Pack: API Script
# Create a signed webhook for SIEM audit log streaming
EXISTING=$(ld_get "/webhooks" | jq -r '.items[] | select(.name == "HTH SIEM Webhook") | ._id') || true
if [ -n "${EXISTING}" ]; then
info "4.1 SIEM webhook already exists (id: ${EXISTING})"
pass "4.1 Audit log webhook configured"
increment_applied
else
WEBHOOK_SECRET="${LD_WEBHOOK_SECRET:-$(openssl rand -hex 32)}"
info "4.1 Creating signed webhook for SIEM..."
RESPONSE=$(ld_post "/webhooks" "$(jq -n \
--arg url "${LD_SIEM_WEBHOOK_URL}" \
--arg secret "${WEBHOOK_SECRET}" \
'{
"name": "HTH SIEM Webhook",
"url": $url,
"sign": true,
"secret": $secret,
"on": true,
"tags": ["hth", "siem"],
"statements": [
{
"effect": "allow",
"actions": ["*"],
"resources": ["proj/*"]
}
]
}'
)") || {
fail "4.1 Failed to create SIEM webhook"
increment_failed; summary; exit 0
}
pass "4.1 SIEM webhook created"
info "4.1 Webhook signing secret: ${WEBHOOK_SECRET}"
increment_applied
fi
Code Pack: Sigma Detection Rule
detection:
selection_webhook:
kind: 'webhook'
accesses.action:
- 'deleteWebhook'
selection_integration:
kind: 'integration'
accesses.action:
- 'deleteIntegration'
condition: selection_webhook or selection_integration
fields:
- date
- title
- member.email
- target.name
- accesses.resource
Appendix A: Edition Compatibility
| Control | Pro | Enterprise |
|---|---|---|
| SAML SSO | ✅ | ✅ |
| SCIM | ❌ | ✅ |
| Custom Roles | ✅ | ✅ |
| Approval Workflows | ❌ | ✅ |
Appendix B: References
Official LaunchDarkly Documentation:
API & Developer Resources:
Compliance Frameworks:
- SOC 2 Type II, ISO 27001, ISO 27701, FedRAMP Moderate ATO, HIPAA – compliance reports available upon request via LaunchDarkly Support
Security Incidents:
- No major public security breaches identified as of this writing.
Changelog
| Date | Version | Maturity | Changes | Author |
|---|---|---|---|---|
| 2025-12-14 | 0.1.0 | draft | Initial LaunchDarkly hardening guide | Claude Code (Opus 4.5) |