v0.2.3 AI Drafted AI Validated

Cloudflare Zero Trust Hardening Guide

Security Last updated: 2026-09-25

Security hardening for Cloudflare Zero Trust, Access, Gateway, and WARP deployment

View:

Overview

Cloudflare Zero Trust is a comprehensive security platform providing secure access to applications, DNS filtering, and endpoint protection. With billions of DNS queries processed daily and protection for millions of users, Cloudflare’s Zero Trust services are critical infrastructure for modern security architectures. This guide covers hardening Access (ZTNA), Gateway (SWG/CASB), and WARP (endpoint agent).

Intended Audience

  • Security engineers managing Cloudflare Zero Trust deployments
  • IT administrators configuring access policies
  • GRC professionals assessing Zero Trust compliance
  • Third-party risk managers evaluating security tools

How to Use This Guide

  • L1 (Crawl): Essential controls for all organizations
  • L2 (Walk): Enhanced controls for security-sensitive environments
  • L3 (Run): Strictest controls for regulated industries

Scope

This guide covers Cloudflare Zero Trust components including Access, Gateway, WARP client, and Tunnel configurations. CDN and DDoS protection are covered in separate guides.


Table of Contents

  1. Authentication & Access Controls
  2. Access Application Policies
  3. Gateway Security Policies
  4. WARP Client Hardening
  5. Tunnel Security
  6. Monitoring & Detection
  7. Compliance Quick Reference

1. Authentication & Access Controls

1.1 Configure Identity Provider Integration

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.3, 12.5
NIST 800-53 IA-2, IA-8

Description

Integrate Cloudflare Zero Trust with your corporate identity provider to enable SSO authentication for Access applications and WARP enrollment.

Rationale

Why This Matters:

  • Centralizes authentication management
  • Enables MFA through your IdP
  • Provides consistent identity across all Zero Trust services
  • Enables user and group-based policies

Attack Prevented: Account takeover via fragmented authentication, access without IdP-enforced MFA

Prerequisites

  • Cloudflare Zero Trust account
  • Identity provider with OIDC or SAML support
  • Admin access to Zero Trust dashboard

ClickOps Implementation

Step 1: Add Identity Provider

  1. Navigate to: Zero Trust → Integrations → Identity providers
  2. Select Add an identity provider
  3. Under Select an identity provider, choose your IdP type:
    • Okta, Microsoft Entra ID, OneLogin: Use preconfigured templates
    • OpenID Connect / SAML: Manual configuration
  4. Configure IdP settings (OpenID Connect form shown):
    • App ID / Client secret: From IdP application
    • Auth URL: IdP OAuth endpoint
    • Token URL: IdP token endpoint
    • Certificate URL: The IdP’s jwks_uri endpoint, used to verify token signatures
    • (Optional) Turn on Proof Key for Code Exchange (PKCE) if your IdP supports it

Step 2: Configure IdP (Example: Okta)

  1. In Okta Admin: Applications → Create App Integration
  2. Select OIDC - Web Application
  3. Configure:
    • Sign-in redirect: https://<team-name>.cloudflareaccess.com/cdn-cgi/access/callback
    • Sign-out redirect: https://<team-name>.cloudflareaccess.com
  4. Assign users/groups
  5. Copy the Okta Client ID and Client secret into Cloudflare’s App ID and Client secret fields

Step 3: Test Authentication

  1. Go to Integrations → Identity providers and select Test next to the IdP
  2. Verify successful authentication
  3. In each Access application’s Authentication → Identity section, and in device enrollment (see 1.3), turn off Accept all available identity providers and select only your IdP

Time to Complete: ~45 minutes


Code Pack: Terraform
hth-cloudflare-1.01-configure-identity-provider.tf View source on GitHub ↗
resource "cloudflare_zero_trust_access_identity_provider" "corporate_idp" {
  account_id = var.cloudflare_account_id
  name       = "Corporate IdP"
  type       = "oidc"

  config = {
    client_id     = var.oidc_client_id
    client_secret = var.oidc_client_secret
    auth_url      = var.oidc_auth_url
    token_url     = var.oidc_token_url
    certs_url     = var.oidc_certs_url
    pkce_enabled  = true
    claims        = ["email_verified", "preferred_username", "groups"]
    scopes        = ["openid", "email", "profile", "groups"]
  }
}
Code Pack: API Script
hth-cloudflare-1.01-configure-identity-provider.sh View source on GitHub ↗
# Add OIDC identity provider to Zero Trust
info "1.1 Adding OIDC identity provider..."
: "${CF_IDP_CLIENT_ID:?Set CF_IDP_CLIENT_ID}"
: "${CF_IDP_CLIENT_SECRET:?Set CF_IDP_CLIENT_SECRET}"
: "${CF_IDP_AUTH_URL:?Set CF_IDP_AUTH_URL}"
: "${CF_IDP_TOKEN_URL:?Set CF_IDP_TOKEN_URL}"
: "${CF_IDP_CERTS_URL:?Set CF_IDP_CERTS_URL (the IdP jwks_uri endpoint)}"

# Build the body with jq so a secret containing quotes or backslashes still
# produces valid JSON.
BODY=$(jq -n \
  --arg id "${CF_IDP_CLIENT_ID}" \
  --arg sec "${CF_IDP_CLIENT_SECRET}" \
  --arg au "${CF_IDP_AUTH_URL}" \
  --arg tu "${CF_IDP_TOKEN_URL}" \
  --arg cu "${CF_IDP_CERTS_URL}" \
  '{
    name: "Corporate IdP",
    type: "oidc",
    config: {
      client_id: $id,
      client_secret: $sec,
      auth_url: $au,
      token_url: $tu,
      certs_url: $cu,
      pkce_enabled: true,
      claims: ["email_verified", "preferred_username", "groups"],
      scopes: ["openid", "email", "profile", "groups"]
    }
  }')

RESPONSE=$(cf_post "/accounts/${CF_ACCOUNT_ID}/access/identity_providers" "${BODY}") || {
  fail "1.1 Failed to add identity provider"
  increment_failed
  summary
  exit 0
}
Code Pack: Sigma Detection Rule
hth-cloudflare-1.01-configure-identity-provider.yml View source on GitHub ↗
detection:
    selection:
        ActionType|contains:
            - 'DeleteAccessIdentityProvider'
            - 'UpdateAccessIdentityProvider'
    condition: selection
fields:
    - ActorEmail
    - ActionType
    - ResourceID
    - When

1.2 Configure Multi-Factor Authentication

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.5
NIST 800-53 IA-2(1)

Description

Ensure MFA is enforced for all Access application authentications through IdP policies or Cloudflare’s additional MFA requirements.

Rationale

Why This Matters:

  • Passwords alone are routinely defeated by phishing, credential stuffing, and reuse — MFA adds a second factor an attacker is far less likely to possess
  • Cloudflare Access sits in front of internal and SaaS applications, so a single bypassed login can expose every protected resource
  • Enforcing MFA at the IdP or in the Access policy guarantees the requirement applies to every authentication, not just the logins users choose to protect
  • Phishing-resistant factors (FIDO2/WebAuthn) defeat real-time relay attacks that one-time codes cannot stop

Attack Prevented: Credential theft, phishing, credential stuffing, password reuse, account takeover

ClickOps Implementation

Option A: Enforce MFA via IdP (Recommended)

  1. Configure MFA requirement in your identity provider
  2. Create IdP policy requiring MFA for Cloudflare application
  3. All Access authentications will require MFA

Option B: Cloudflare Access Policy Requirement

  1. Navigate to: Zero Trust → Access controls → Policies and edit the policy
  2. Add a rule:
    • Rule type: Require (select + Add require (AND))
    • Selector: Authentication Method
    • Value: MFA (Multiple-factor authentication)
  3. Select Save policy; Access rejects a user who does not present the required MFA method, even after a successful IdP login (the IdP must report the authentication method it used)

Option C: Cloudflare Independent MFA

  1. Navigate to: Zero Trust → Access controls → Access settings
  2. Under Allow multi-factor authentication (MFA), select the MFA methods to allow
  3. For each application, leave MFA set to Respect global enforcement setting, or require it per application or policy (the per-application MFA options appear only after at least one MFA method is allowed in Access settings)

Code Pack: Terraform
hth-cloudflare-1.02-configure-mfa.tf View source on GitHub ↗
resource "cloudflare_zero_trust_access_policy" "require_mfa" {
  account_id = var.cloudflare_account_id
  name       = "Require MFA for all users"
  decision   = "allow"

  include = [{
    email_domain = {
      domain = var.corporate_domain
    }
  }]

  require = [{
    auth_method = {
      auth_method = "mfa"
    }
  }]

  session_duration = "24h"
}
Code Pack: API Script
hth-cloudflare-1.02-configure-mfa.sh View source on GitHub ↗
# Independent MFA at one level (application or policy): "on", "off" or "inherit"
MFA_LEVEL='def mfa_level:
  if .mfa_config == null then "inherit"
  elif (.mfa_config.mfa_disabled // false) then "off"
  elif ((.mfa_config.allowed_authenticators // []) | length) > 0
    or ((.mfa_config.session_duration // "") != "") then "on"
  else "inherit" end;'

# Every Allow or Bypass policy must require MFA, by rule or by Independent MFA
MFA_MISSING=0
FETCH_ERR=0
while IFS= read -r app; do
  APP_ID=$(echo "${app}" | jq -r '.id')
  APP_NAME=$(echo "${app}" | jq -r '.name')
  APP_MFA=$(echo "${app}" | jq -r "${MFA_LEVEL} mfa_level")

  APP_POLICIES=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps/${APP_ID}/policies") || {
    fail "1.2 Could not read policies for application '${APP_NAME}'"
    FETCH_ERR=1
    continue
  }
  GAPS=$(echo "${APP_POLICIES}" | jq -r --arg app "${APP_MFA}" --argjson org "${ORG_MFA_DEFAULT}" "${MFA_LEVEL}"'
    .result[]
    | (.decision // "allow") as $d
    | select($d == "allow" or $d == "bypass")
    | (.name // "unnamed policy") as $n
    | if $d == "bypass" then "\($n): Bypass skips Access login, so MFA cannot apply"
      elif any(.require[]?; .auth_method.auth_method? == "mfa") then empty
      else mfa_level as $p
        | (if $p != "inherit" then $p elif $app != "inherit" then $app
           elif $org then "on" else "off" end) as $effective
        | if $effective == "on" then empty
          elif $p == "off" then "\($n): policy disables Independent MFA and has no MFA Require rule"
          else "\($n): no MFA Require rule and Independent MFA does not apply" end
      end')

  if [ -n "${GAPS}" ]; then
    warn "1.2 Application '${APP_NAME}' admits users without MFA:"
    while IFS= read -r gap; do
      echo "    - ${gap}"
    done < <(printf '%s\n' "${GAPS}")
    MFA_MISSING=$((MFA_MISSING + 1))
  fi
done < <(echo "${APPS}" | jq -c '.result[]')

1.3 Harden Device Enrollment

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 1.4, 5.3
NIST 800-53 AC-2

Description

Configure device enrollment policies to control which devices can enroll in WARP and access your Zero Trust network.

Rationale

Why This Matters:

  • Once enrolled, devices join your Zero Trust network
  • Uncontrolled enrollment creates security risk
  • Enrollment policies prevent unauthorized device access

Attack Prevented: Unauthorized or rogue device enrollment into the Zero Trust network

ClickOps Implementation

Step 1: Configure Enrollment Policies

  1. Navigate to: Zero Trust → Team & Resources → Devices → Management
  2. In Device enrollment → Device enrollment permissions, select Manage
  3. Under Device enrollment policies, select Create new policy (or Add current policies) and restrict who can enroll:
    • Emails ending in: @yourdomain.com
    • Identity provider groups: Specific groups only
    • Country: Allowed countries only
  4. Device posture checks are not supported in enrollment policies; the client can only run posture checks after the device is enrolled

Step 2: Require IdP Authentication

  1. Under Authentication → Identity, turn off Accept all available identity providers and select only your corporate IdP
  2. (Optional) If users sign in through a single IdP, turn on Apply instant authentication to send them straight to your SSO login
  3. Save

Time to Complete: ~20 minutes


Code Pack: Terraform
hth-cloudflare-1.03-harden-device-enrollment.tf View source on GitHub ↗
resource "cloudflare_zero_trust_access_application" "warp_enrollment" {
  account_id       = var.cloudflare_account_id
  name             = "Device Enrollment"
  type             = "warp"
  session_duration = "24h"

  allowed_idps              = var.allowed_idp_ids
  auto_redirect_to_identity = true

  # Enrollment is governed by the policies attached here
  policies = [{
    id         = cloudflare_zero_trust_access_policy.device_enrollment_policy.id
    precedence = 1
  }]
}

resource "cloudflare_zero_trust_access_policy" "device_enrollment_policy" {
  account_id = var.cloudflare_account_id
  name       = "Restrict device enrollment to corporate users"
  decision   = "allow"

  include = [{
    email_domain = {
      domain = var.corporate_domain
    }
  }]

  # Device posture checks are not supported in enrollment policies
  require = [{
    auth_method = {
      auth_method = "mfa"
    }
  }]
}
Code Pack: API Script
hth-cloudflare-1.03-harden-device-enrollment.sh View source on GitHub ↗
# Find the device enrollment application (type "warp") and audit its policies
APPS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps") || {
  fail "1.3 Unable to retrieve Access applications"
  increment_failed
  summary
  exit 0
}

WARP_APP=$(echo "${APPS}" | jq -r '[.result[] | select(.type == "warp")][0].id // empty')
if [ -z "${WARP_APP}" ]; then
  fail "1.3 No device enrollment permissions configured (no Access application of type warp)"
  increment_failed
  summary
  exit 0
fi

POLICIES=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps/${WARP_APP}/policies") || {
  fail "1.3 Unable to retrieve device enrollment policies"
  increment_failed
  summary
  exit 0
}

POLICY_COUNT=$(echo "${POLICIES}" | jq '.result | length')
OPEN_ALLOW=$(echo "${POLICIES}" | jq '[.result[] | select(.decision == "allow") | select(any(.include[]?; has("everyone")))] | length')

1.4 Configure Admin Role Restrictions

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 5.4
NIST 800-53 AC-6(1)

Description

Configure granular admin roles in Cloudflare to limit dashboard access based on job responsibilities.

Rationale

Why This Matters:

  • Super Administrator access grants full control over Zero Trust policies, DNS, and account settings — a compromised admin account can disable every protection at once
  • Assigning least-privilege roles limits the blast radius if any single admin credential is phished or stolen
  • Scoped roles such as Cloudflare Zero Trust and Audit Logs Viewer let teams do their jobs without holding billing or account-wide change rights
  • Fewer privileged accounts means a smaller, more defensible attack surface for adversaries to target

Attack Prevented: Privilege escalation, insider misuse, account takeover, unauthorized configuration change

ClickOps Implementation

Step 1: Review Member Access

  1. Navigate to: Cloudflare Dashboard → Manage Account → Members
  2. Review current member roles
  3. Document Super Administrator assignments

Step 2: Implement Least Privilege

  1. Available roles:
    • Super Administrator - All Privileges: Full access (limit to 2-3)
    • Administrator: Full account access and subscriptions; cannot manage members or the billing profile
    • Cloudflare Zero Trust: Administrator access to Zero Trust products only (Cloudflare Zero Trust Read Only for read access)
    • Audit Logs Viewer: Read-only access to Audit Logs
  2. Assign appropriate roles per responsibility
  3. Remove unnecessary Super Administrator access

Code Pack: Terraform
hth-cloudflare-1.04-configure-admin-roles.tf View source on GitHub ↗
data "cloudflare_account_roles" "all" {
  account_id = var.cloudflare_account_id
}

locals {
  roles_by_name = {
    for role in data.cloudflare_account_roles.all.result :
    role.name => role
  }
}

resource "cloudflare_account_member" "zt_admin" {
  account_id = var.cloudflare_account_id
  email      = var.zt_admin_email
  roles      = [local.roles_by_name["Cloudflare Zero Trust"].id]
}

resource "cloudflare_account_member" "audit_viewer" {
  account_id = var.cloudflare_account_id
  email      = var.audit_viewer_email
  roles      = [local.roles_by_name["Audit Logs Viewer"].id]
}
Code Pack: API Script
hth-cloudflare-1.04-configure-admin-roles.sh View source on GitHub ↗
# Collect every page of account members
ALL_MEMBERS='[]'
PAGE=1
TOTAL_PAGES=1
while [ "${PAGE}" -le "${TOTAL_PAGES}" ]; do
  RESP=$(cf_get "/accounts/${CF_ACCOUNT_ID}/members?page=${PAGE}&per_page=50") || {
    fail "1.4 Unable to retrieve account members (page ${PAGE})"
    increment_failed
    summary
    exit 0
  }
  ALL_MEMBERS=$(jq -n --argjson acc "${ALL_MEMBERS}" --argjson page "${RESP}" '$acc + $page.result')
  # result_info carries total_count and per_page; derive the page count from them
  TOTAL_PAGES=$(echo "${RESP}" | jq '.result_info as $i
    | ($i.total_pages // (if (($i.total_count // 0) == 0) then 1
        else ((($i.total_count + ($i.per_page // 50) - 1) / ($i.per_page // 50)) | floor) end))')
  PAGE=$((PAGE + 1))
done

MEMBER_COUNT=$(echo "${ALL_MEMBERS}" | jq 'length')
info "1.4 Found ${MEMBER_COUNT} account member(s)"

# The role is named "Super Administrator - All Privileges"
SUPER_ADMINS=$(echo "${ALL_MEMBERS}" | jq '[.[] | select(any(.roles[]?; .name | startswith("Super Administrator")))] | length')
if [ "${SUPER_ADMINS}" -gt 3 ]; then
  fail "1.4 ${SUPER_ADMINS} Super Administrators found (recommend max 2-3)"
elif [ "${SUPER_ADMINS}" -eq 0 ]; then
  fail "1.4 No member reported the Super Administrator role -- check the token can read member roles"
else
  pass "1.4 ${SUPER_ADMINS} Super Administrator(s) found (within recommended limit)"
fi

# Member count per role (no email addresses)
echo "${ALL_MEMBERS}" | jq -r '[.[].roles[]?.name] | group_by(.) | .[] | "  - \(.[0]): \(length) member(s)"'
Code Pack: Sigma Detection Rule
hth-cloudflare-1.04-configure-admin-roles.yml View source on GitHub ↗
detection:
    selection:
        ActionType: 'UpdateMember'
    filter_role:
        Metadata|contains: 'Super Administrator'
    condition: selection and filter_role
fields:
    - ActorEmail
    - ActionType
    - ResourceID
    - When

1.5 Retire the Global API Key and Enforce Scoped API Tokens

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 5.4, 6.8
NIST 800-53 AC-6(1), IA-5

Description

Eliminate use of the Global API Key — a single credential with full account-wide privileges that never expires — and replace it with scoped API tokens that carry explicit permissions, an expiry (TTL), and client IP restrictions. For automation, CI/CD, and Terraform, use Account-Owned API Tokens so credentials belong to the account rather than to an individual member. Cloudflare states directly that the Global API Key is not recommended and that customers should migrate to API tokens (Cloudflare API keys documentation).

Rationale

Why This Matters:

  • The Global API Key grants full control of every zone and account setting the user can reach, so a single leaked key is equivalent to a full account takeover with no way to limit the damage short of rotating it
  • The Global API Key has no scope, no expiry, and no IP restriction, which means a key pasted into a script, log, or support ticket stays valid indefinitely — Cloudflare rotated 104 API tokens after the 2025 Salesloft Drift incident precisely because credentials end up in support case text
  • Scoped API tokens grant only the specific permissions a task needs (for example, DNS edit on one zone) and support a TTL and Client IP Address Filtering, so a stolen token is time-bound and usable only from expected networks
  • Member-owned tokens die when the member is offboarded, silently breaking production automation; Account-Owned API Tokens (generally available since November 2024) are scoped to the account rather than to a person, so Terraform and CI/CD credentials survive admin turnover and are managed centrally by Super Administrators (Cloudflare blog: account-owned tokens)

Attack Prevented: Full-account takeover via leaked credentials, unlimited credential lifetime, lateral privilege escalation, orphaned automation credentials surviving offboarding

ClickOps Implementation

Step 1: Inventory Global API Key Usage

  1. Navigate to: Cloudflare Dashboard → My Profile → API Tokens
  2. Under API Keys, note whether the Global API Key has been viewed or distributed
  3. Search internal scripts, CI/CD secret stores, Terraform variable files, and runbooks for the header names X-Auth-Email and X-Auth-Key — these indicate Global API Key usage that must be migrated

Step 2: Create a Scoped User API Token

  1. Navigate to: My Profile → API Tokens → Create Token
  2. Start from a template or select Create Custom Token
  3. Under Permissions, grant only the specific product, scope, and level required (for example, Zone → DNS → Edit)
  4. Under Zone Resources / Account Resources, restrict the token to the exact zones or accounts it needs — never “All zones” unless genuinely required
  5. Under Client IP Address Filtering, add the egress IP or CIDR of the system that will use the token
  6. Under TTL, set an explicit start and expiry date rather than leaving the token permanent
  7. Click Continue to summary → Create Token and store the value in a secrets manager immediately (it is displayed only once)

Step 3: Create Account-Owned Tokens for Automation (L2)

  1. Navigate to: Manage Account → Account API Tokens (account scope, not profile scope)
  2. Click Create Token and configure permissions, resources, IP filtering, and TTL as above
  3. Use these tokens for Terraform, CI/CD pipelines, and any integration that must outlive an individual employee
  4. Restrict who can create and view account-owned tokens to Super Administrators

Step 4: Decommission the Global API Key

  1. Migrate every remaining consumer to a scoped token
  2. Navigate to: My Profile → API Tokens → API Keys → Global API Key → Change to roll the key, invalidating any copies that remain in circulation
  3. Repeat the roll for every account member who has ever retrieved their Global API Key

Time to Complete: ~60 minutes plus migration effort

Validation & Testing

  1. Confirm no running system authenticates with X-Auth-Email / X-Auth-Key headers — all callers should send an Authorization: Bearer token instead
  2. Verify each token’s restrictions by calling the token verification endpoint from an IP outside the allowlist; the request must fail
  3. Attempt an action outside the token’s granted permissions (for example, editing a zone the token does not cover) and confirm it is rejected
  4. Review Audit Logs for token creation events and confirm every token has a named owner and documented purpose
  5. Set a calendar reminder ahead of each token’s TTL expiry so rotation is planned rather than reactive

Compliance Mappings

Framework Control Requirement
CIS Controls v8 5.4 Restrict administrator privileges to dedicated administrator accounts
CIS Controls v8 6.8 Define and maintain role-based access control
NIST 800-53 Rev 5 AC-6(1) Authorize access to security functions on a least-privilege basis
NIST 800-53 Rev 5 IA-5 Authenticator management, including lifetime and rotation
SOC 2 CC6.1 Logical access credentials are restricted and managed

Code Pack: Terraform
hth-cloudflare-1.05-scoped-account-token.tf View source on GitHub ↗
data "cloudflare_account_api_token_permission_groups_list" "selected" {
  for_each   = toset(var.automation_token_permission_groups)
  account_id = var.cloudflare_account_id
  name       = each.value
}

resource "cloudflare_account_token" "automation" {
  account_id = var.cloudflare_account_id
  name       = "hth-automation"
  expires_on = var.automation_token_expires_on

  policies = [{
    effect = "allow"
    permission_groups = [
      for name in var.automation_token_permission_groups : {
        id = data.cloudflare_account_api_token_permission_groups_list.selected[name].result[0].id
      }
    ]
    resources = jsonencode({
      "com.cloudflare.api.account.${var.cloudflare_account_id}" = "*"
    })
  }]

  condition = {
    request_ip = {
      in = var.automation_token_allowed_cidrs
    }
  }
}
Code Pack: API Script
hth-cloudflare-1.05-audit-api-tokens.sh View source on GitHub ↗
# Flag active tokens that never expire or accept requests from any IP
FINDINGS=0
FETCH_ERR=0
for scope in "/user/tokens" "/accounts/${CF_ACCOUNT_ID}/tokens"; do
  TOKENS=$(cf_get_all "${scope}") || {
    fail "1.5 Unable to list tokens at ${scope}"
    FETCH_ERR=1
    continue
  }
  ACTIVE=$(echo "${TOKENS}" | jq '[.result[] | select(.status == "active")] | length')
  NO_EXPIRY=$(echo "${TOKENS}" | jq -r '.result[] | select(.status == "active") | select((.expires_on // "") == "") | .name')
  NO_IP=$(echo "${TOKENS}" | jq -r '.result[] | select(.status == "active") | select(((.condition.request_ip.in // []) | length) == 0) | .name')
  info "1.5 ${scope}: ${ACTIVE} active token(s)"
  while IFS= read -r name; do
    [ -z "${name}" ] && continue
    warn "1.5 Token '${name}' has no expiry (TTL)"
    FINDINGS=$((FINDINGS + 1))
  done < <(printf '%s\n' "${NO_EXPIRY}")
  while IFS= read -r name; do
    [ -z "${name}" ] && continue
    warn "1.5 Token '${name}' has no client IP restriction"
    FINDINGS=$((FINDINGS + 1))
  done < <(printf '%s\n' "${NO_IP}")
done

1.6 Enforce Two-Factor Authentication for Account Members

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.5
NIST 800-53 IA-2(1)

Description

Turn on account-level 2FA Enforcement so that every Cloudflare account member must have two-factor authentication enabled before they can accept an invitation or continue using the account. This control protects the administrators of the platform itself and is distinct from the MFA you require of end users through Access policies (section 1.2) (Cloudflare two-factor authentication documentation).

Rationale

Why This Matters:

  • Access policies enforce MFA for the people using protected applications, but they do nothing for the administrators who log into the Cloudflare dashboard — those accounts control DNS, WAF, Zero Trust policy, and tunnel configuration for the entire estate
  • A single compromised administrator password without 2FA lets an attacker disable Gateway policies, publish a tunnel hostname without an Access application, or repoint DNS, defeating every other control in this guide at once
  • Relying on individual members to enable 2FA voluntarily produces uneven coverage; account-level enforcement makes it a condition of membership, so a member who has not enrolled cannot accept an invite or keep using the account
  • Enforcement applies continuously rather than only at invitation time, so members who disable 2FA later are prompted back into compliance instead of silently dropping below the baseline

Attack Prevented: Administrator credential theft, phishing of dashboard logins, password reuse leading to platform-wide configuration compromise, account takeover

ClickOps Implementation

Step 1: Enable 2FA on Your Own Account First

  1. Navigate to: My Profile → Access Management → Authentication
  2. Under Two-Factor Authentication, select Add next to Security Key Authentication (preferred) or Mobile App Authentication
  3. Complete enrolment and verification
  4. Download and securely store the recovery (backup) codes Cloudflare generates after setup — enforcement will lock out an unenrolled Super Administrator

Step 2: Turn On Account-Level 2FA Enforcement

  1. Navigate to: Manage Account → Members → Settings
  2. Locate Require two-factor authentication (2FA) for all members (available to Super Administrators; you must turn on 2FA for yourself first)
  3. Turn on enforcement for the account
  4. Members without 2FA are required to enable it before accepting an invitation or continuing to use the account

Step 3: Communicate and Remediate

  1. Notify members ahead of enabling enforcement so they can enrol without disruption
  2. Review Manage Account → Members for anyone in a pending or non-compliant state
  3. Prefer hardware security keys (WebAuthn) over one-time codes for Super Administrators, since keys resist real-time phishing relay

Step 4: Pair with SSO Where Available (L2)

  1. Where your plan offers it, configure Single Sign-on (SSO) for all members on the same Members → Settings tab so administrator authentication inherits the IdP’s phishing-resistant factors and conditional access rules (enable the identity provider in Zero Trust first)
  2. Keep 2FA enforcement enabled as a backstop for any account not covered by SSO

Time to Complete: ~30 minutes

Validation & Testing

  1. From Manage Account → Members, confirm every member shows a compliant 2FA status
  2. Invite a test member and confirm the invitation cannot be accepted until 2FA is configured
  3. Attempt a dashboard login with a valid password on a test account without 2FA and confirm the second factor is demanded
  4. Review Audit Logs for 2fa and membership events to confirm enforcement was enabled and by whom
  5. Re-check member compliance on a recurring schedule (at least quarterly) as part of access review

Compliance Mappings

Framework Control Requirement
CIS Controls v8 6.5 Require MFA for administrative access
NIST 800-53 Rev 5 IA-2(1) Multi-factor authentication to privileged accounts
SOC 2 CC6.1 Authentication controls restrict access to authorized users
ISO 27001:2022 A.5.17 Authentication information management

Code Pack: Terraform
hth-cloudflare-1.06-verify-2fa-enforcement.tf View source on GitHub ↗
check "account_2fa_enforced" {
  data "cloudflare_account" "this" {
    account_id = var.cloudflare_account_id
  }

  assert {
    condition     = try(data.cloudflare_account.this.settings.enforce_twofactor, false) == true
    error_message = "Account-level 2FA enforcement is off: members can use the account without two-factor authentication."
  }
}
Code Pack: API Script
hth-cloudflare-1.06-audit-2fa-enforcement.sh View source on GitHub ↗
# Read the account setting that makes 2FA a condition of membership
ACCOUNT=$(cf_get "/accounts/${CF_ACCOUNT_ID}") || {
  fail "1.6 Unable to retrieve account settings"
  increment_failed
  summary
  exit 0
}
ENFORCED=$(echo "${ACCOUNT}" | jq -r '.result.settings.enforce_twofactor // false')

MEMBERS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/members") || {
  fail "1.6 Unable to retrieve account members"
  increment_failed
  summary
  exit 0
}
NO_2FA=$(echo "${MEMBERS}" | jq '[.result[] | select(.user.two_factor_authentication_enabled == false)] | length')

2. Access Application Policies

2.1 Create Secure Application Policies

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.4
NIST 800-53 AC-3, AC-6

Description

Create Access policies that protect applications with identity-based, context-aware access controls.

Rationale

Why This Matters:

  • Access policies define who can access each application
  • Granular controls enable Zero Trust access
  • Policies can require specific device posture
  • Replaces VPN with identity-aware access

Attack Prevented: Unauthorized application access, lateral movement via broad VPN-style network access

ClickOps Implementation

Step 1: Add Application

  1. Navigate to: Zero Trust → Access controls → Applications
  2. Click Add an application
  3. Select application type:
    • Self-hosted and private: Public hostnames and private IPs or hostnames, including applications behind Cloudflare Tunnel
    • SaaS applications: Third-party SaaS applications
    • Infrastructure: SSH targets (see 2.4)

Step 2: Configure Application Settings

  1. Under Destinations, add the application’s public hostname (subdomain and domain), or a private IP or hostname
  2. Under Details, set:
    • Name: Descriptive application name
    • Session Duration: 24 hours (adjust as needed)

Step 3: Create Access Policy

  1. Under Access policies, select Create new policy (or Add current policies to reuse an existing one)
  2. Configure policy rules:
    • Policy Name: “Allow Engineering Team”
    • Action: Allow
    • Include rules:
      • Emails ending in: @yourdomain.com
      • Identity provider group: Engineering
    • Require rules:
      • Login Methods: Your IdP
      • Warp (under Device Posture Checks): requires the Warp posture check from 2.2

Step 4: Harden Policy (L2)

  1. Add additional require rules:
    • Warp: Require the device client
    • Device Posture Checks: Require compliant device
    • Country (under Location): Restrict to specific countries
  2. Add block rules for exceptions if needed

Time to Complete: ~30 minutes per application


Code Pack: Terraform
hth-cloudflare-2.01-create-access-policies.tf View source on GitHub ↗
resource "cloudflare_zero_trust_access_group" "employees" {
  account_id = var.cloudflare_account_id
  name       = "All Employees"

  include = [{
    email_domain = {
      domain = var.corporate_domain
    }
  }]
}

resource "cloudflare_zero_trust_access_application" "internal_app" {
  zone_id          = var.cloudflare_zone_id
  name             = "Internal Application"
  domain           = var.internal_app_domain
  type             = "self_hosted"
  session_duration = "8h"

  allowed_idps              = var.allowed_idp_ids
  auto_redirect_to_identity = true

  # In provider v5 the application carries the policy binding
  policies = [{
    id         = cloudflare_zero_trust_access_policy.allow_employees.id
    precedence = 1
  }]
}

resource "cloudflare_zero_trust_access_policy" "allow_employees" {
  account_id = var.cloudflare_account_id
  name       = "Allow authenticated employees"
  decision   = "allow"

  include = [{
    group = {
      id = cloudflare_zero_trust_access_group.employees.id
    }
  }]

  require = [{
    auth_method = {
      auth_method = "mfa"
    }
  }]

  session_duration = "8h"
}
Code Pack: API Script
hth-cloudflare-2.01-create-access-policies.sh View source on GitHub ↗
# List all Access applications and check policy configuration
APPS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps") || {
  fail "2.1 Unable to retrieve Access applications"
  increment_failed
  summary
  exit 0
}

APP_COUNT=$(echo "${APPS}" | jq '.result | length')
info "2.1 Found ${APP_COUNT} Access application(s)"

UNPROTECTED=0
FETCH_ERR=0
while IFS= read -r app_line; do
  APP_ID=$(echo "${app_line}" | jq -r '.id')
  APP_NAME=$(echo "${app_line}" | jq -r '.name')
  APP_DOMAIN=$(echo "${app_line}" | jq -r '.domain // "N/A"')

  POLICIES=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps/${APP_ID}/policies") || {
    fail "2.1 Could not read policies for application '${APP_NAME}'"
    FETCH_ERR=1
    continue
  }
  POLICY_COUNT=$(echo "${POLICIES}" | jq '.result | length')
  # Bypass switches Access off for whoever it includes; including Everyone
  # makes the application public, whatever its other policies say
  OPEN_BYPASS=$(echo "${POLICIES}" | jq '[.result[]
    | select(.decision == "bypass")
    | select(any(.include[]?; has("everyone")))] | length')
  IDENTITY_POLICIES=$(echo "${POLICIES}" | jq '[.result[]
    | select((.decision // "allow") != "bypass")] | length')
  OPEN_ALLOW=$(echo "${POLICIES}" | jq '[.result[]
    | select((.decision // "allow") == "allow")
    | select(any(.include[]?; has("everyone")))
    | select(((.require // []) | length) == 0)] | length')

  if [ "${POLICY_COUNT}" = "0" ]; then
    warn "2.1 Application '${APP_NAME}' (${APP_DOMAIN}) has NO Access policies"
    UNPROTECTED=$((UNPROTECTED + 1))
  elif [ "${OPEN_BYPASS}" -gt 0 ]; then
    warn "2.1 Application '${APP_NAME}' (${APP_DOMAIN}) has a Bypass policy that includes Everyone -- Access is not enforced"
    UNPROTECTED=$((UNPROTECTED + 1))
  elif [ "${IDENTITY_POLICIES}" = "0" ]; then
    warn "2.1 Application '${APP_NAME}' (${APP_DOMAIN}) has only Bypass policies -- no identity-based policy"
    UNPROTECTED=$((UNPROTECTED + 1))
  else
    info "2.1 Application '${APP_NAME}' has ${POLICY_COUNT} policy(s)"
    if [ "${OPEN_ALLOW}" -gt 0 ]; then
      warn "2.1 Application '${APP_NAME}': an Allow policy includes Everyone with no Require rule -- any identity from any login method gets in"
    fi
  fi
done < <(echo "${APPS}" | jq -c '.result[]')
Code Pack: Sigma Detection Rule
hth-cloudflare-2.01-create-access-policies.yml View source on GitHub ↗
detection:
    selection:
        ActionType|contains:
            - 'DeleteAccessPolicy'
            - 'DeleteAccessApplication'
    condition: selection
fields:
    - ActorEmail
    - ActionType
    - ResourceID
    - When

2.2 Require WARP for Application Access

Profile Level: L2 (Walk)

Framework Control
CIS Controls 4.1, 6.4
NIST 800-53 AC-2(11)

Description

Configure Access policies to require WARP client for application access, enabling device posture checks and additional security controls.

Rationale

Why This Matters:

  • Requiring WARP ensures every request to a protected application originates from a managed, enrolled device rather than an arbitrary browser
  • WARP routes traffic through Gateway, so all access is subject to DNS, HTTP, and network inspection instead of bypassing security controls
  • Device posture signals such as encryption, OS version, and security agents can only be evaluated when the WARP client is present and connected
  • Blocking non-WARP access closes the gap where stolen credentials alone would otherwise be sufficient to reach sensitive apps

Attack Prevented: Unmanaged-device access, credential-only access, security-control bypass, data exfiltration

ClickOps Implementation

Step 1: Enable WARP Requirement in Policy

  1. Navigate to: Zero Trust → Traffic controls → Traffic settings and confirm Allow Secure Web Gateway to proxy traffic is on
  2. Navigate to: Zero Trust → Reusable components → Posture checks
  3. Under Cloudflare One Client checks, select Add a check → Warp, name it, and select Save
  4. Navigate to: Zero Trust → Access controls → Policies and edit the Allow policy the application uses
  5. Select + Add require (AND), choose Warp under Device Posture Checks, and select Save policy

Step 2: Configure WARP-Only Access

  1. For sensitive applications, block non-WARP access
  2. This ensures all traffic passes through Gateway for inspection

Code Pack: Terraform
hth-cloudflare-2.02-require-warp.tf View source on GitHub ↗
resource "cloudflare_zero_trust_device_posture_rule" "warp_connected" {
  account_id  = var.cloudflare_account_id
  name        = "Require WARP Connected"
  type        = "warp"
  description = "Ensure device is running WARP client"

  match = [{
    platform = "windows"
  }, {
    platform = "mac"
  }, {
    platform = "linux"
  }]
}

resource "cloudflare_zero_trust_access_policy" "require_warp" {
  account_id = var.cloudflare_account_id
  name       = "Require WARP for application access"
  decision   = "allow"

  include = [{
    email_domain = {
      domain = var.corporate_domain
    }
  }]

  require = [{
    device_posture = {
      integration_uid = cloudflare_zero_trust_device_posture_rule.warp_connected.id
    }
  }]
}

# The policy enforces only where an application references it
resource "cloudflare_zero_trust_access_application" "warp_required_app" {
  account_id       = var.cloudflare_account_id
  name             = "WARP-Required Application"
  domain           = var.sensitive_app_domain
  type             = "self_hosted"
  session_duration = "8h"

  policies = [{
    id         = cloudflare_zero_trust_access_policy.require_warp.id
    precedence = 1
  }]
}
Code Pack: API Script
hth-cloudflare-2.02-require-warp.sh View source on GitHub ↗
# Check for a WARP device posture rule
POSTURE_RULES=$(cf_get "/accounts/${CF_ACCOUNT_ID}/devices/posture") || {
  fail "2.2 Unable to retrieve device posture rules"
  increment_failed
  summary
  exit 0
}

WARP_RULE_IDS=$(echo "${POSTURE_RULES}" | jq -c '[.result[] | select(.type == "warp") | .id]')
if [ "$(echo "${WARP_RULE_IDS}" | jq 'length')" = "0" ]; then
  fail "2.2 No WARP device posture check found -- add one under Posture checks"
  increment_failed
  summary
  exit 0
fi
info "2.2 WARP device posture check(s): $(echo "${WARP_RULE_IDS}" | jq 'length')"

# Is the WARP check actually required by any application policy?
APPS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps") || {
  fail "2.2 Unable to retrieve Access applications"
  increment_failed
  summary
  exit 0
}

ENFORCING=0
FETCH_ERR=0
while IFS= read -r app_id; do
  APP_POLICIES=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps/${app_id}/policies") || { FETCH_ERR=1; continue; }
  HITS=$(echo "${APP_POLICIES}" | jq --argjson ids "${WARP_RULE_IDS}" \
    '[.result[].require[]? | select(.device_posture.integration_uid as $u | $ids | index($u))] | length')
  [ "${HITS}" -gt 0 ] && ENFORCING=$((ENFORCING + 1))
done < <(echo "${APPS}" | jq -r '.result[].id')

2.3 Configure Device Posture Checks

Profile Level: L2 (Walk)

Framework Control
CIS Controls 4.1
NIST 800-53 AC-2(11)

Description

Define device posture checks to verify endpoint security status before granting application access.

Rationale

Why This Matters:

  • Verified identity alone does not prove the device is safe — a legitimate user on a compromised or non-compliant laptop is still a threat
  • Posture checks for disk encryption, firewall, and OS version enforce a minimum security baseline before access is granted
  • Service-provider checks confirm endpoint security tools such as EDR and anti-malware are actually running, not merely installed
  • Blocking access on posture failure prevents malware-infected or out-of-date endpoints from reaching internal applications and data

Attack Prevented: Compromised-endpoint access, malware lateral movement, data exposure from unencrypted devices

ClickOps Implementation

Step 1: Create Device Posture Rules

  1. Navigate to: Zero Trust → Reusable components → Posture checks
  2. Under Cloudflare One Client checks, select Add a check
  3. Configure posture checks:
    • OS version: Minimum required version
    • Disk encryption: Required (FileVault/BitLocker)
    • Firewall: Enabled (macOS and Windows only)

Step 2: Check Endpoint Security Tools (Optional)

  1. Client checks (added the same way as Step 1):
    • Carbon Black: agent running
    • Client certificate: a device certificate is present
  2. Service-provider integrations such as CrowdStrike connect through the provider’s API; add the provider integration first, then create its posture check

Step 3: Apply to Access Policy

  1. Edit application Access policy
  2. Add posture checks as Require rules
  3. Block access if checks fail

Code Pack: Terraform
hth-cloudflare-2.03-configure-device-posture.tf View source on GitHub ↗
resource "cloudflare_zero_trust_device_posture_rule" "disk_encryption" {
  account_id  = var.cloudflare_account_id
  name        = "Require Disk Encryption"
  type        = "disk_encryption"
  description = "Ensure full-disk encryption is enabled (FileVault/BitLocker)"
  schedule    = "1h"

  input = {
    require_all = true
  }

  match = [{
    platform = "windows"
  }, {
    platform = "mac"
  }]
}

resource "cloudflare_zero_trust_device_posture_rule" "firewall_enabled" {
  account_id  = var.cloudflare_account_id
  name        = "Require Firewall Enabled"
  type        = "firewall"
  description = "Ensure host firewall is enabled"
  schedule    = "1h"

  match = [{
    platform = "windows"
  }, {
    platform = "mac"
  }]
}

resource "cloudflare_zero_trust_device_posture_rule" "os_version" {
  account_id  = var.cloudflare_account_id
  name        = "Minimum OS Version"
  type        = "os_version"
  description = "Require minimum OS version"
  schedule    = "24h"

  input = {
    version  = var.min_os_version
    operator = ">="
  }

  match = [{
    platform = "mac"
  }]
}
Code Pack: API Script
hth-cloudflare-2.03-configure-device-posture.sh View source on GitHub ↗
# List all device posture rules
POSTURE_RULES=$(cf_get "/accounts/${CF_ACCOUNT_ID}/devices/posture") || {
  fail "2.3 Unable to retrieve device posture rules"
  increment_failed
  summary
  exit 0
}

RULE_COUNT=$(echo "${POSTURE_RULES}" | jq '.result | length')
info "2.3 Found ${RULE_COUNT} device posture rule(s)"

# Check for recommended posture checks
HAS_DISK=$(echo "${POSTURE_RULES}" | jq '[.result[] | select(.type == "disk_encryption")] | length')
HAS_FW=$(echo "${POSTURE_RULES}" | jq '[.result[] | select(.type == "firewall")] | length')
HAS_OS=$(echo "${POSTURE_RULES}" | jq '[.result[] | select(.type == "os_version")] | length')

MISSING=()
if [ "${HAS_DISK}" -gt 0 ]; then info "2.3 Disk encryption check configured"; else warn "2.3 No disk encryption posture check found"; MISSING+=("disk_encryption"); fi
if [ "${HAS_FW}" -gt 0 ];   then info "2.3 Firewall check configured";        else warn "2.3 No firewall posture check found";        MISSING+=("firewall"); fi
if [ "${HAS_OS}" -gt 0 ];   then info "2.3 OS version check configured";      else warn "2.3 No OS version posture check found";      MISSING+=("os_version"); fi

echo "${POSTURE_RULES}" | jq -r '.result[] | "  - \(.name) (\(.type))"'

2.4 Replace Long-Lived SSH Keys with Access for Infrastructure

Profile Level: L2 (Walk)

Framework Control
CIS Controls 6.4, 8.5
NIST 800-53 AC-17, IA-5(2), AU-14

Description

Use Cloudflare Access for Infrastructure to broker SSH sessions with short-lived certificates issued at login rather than long-lived private keys distributed to engineers. Targets are registered in Zero Trust, policies bind an identity to a specific target and Unix username, and session commands can be recorded and stored encrypted (Cloudflare SSH with Access for Infrastructure documentation).

Rationale

Why This Matters:

  • Long-lived SSH private keys sit on laptops, in build agents, and in backup archives indefinitely; anyone who obtains a copy has persistent access that no identity provider decision can revoke
  • Short-lived certificates are minted only after a successful Access login, so revoking a user in the IdP or failing a device posture check immediately ends their ability to obtain new SSH sessions
  • Per-target and per-Unix-username policies stop the common pattern where a single shared key grants root-equivalent access to a whole fleet, limiting what one compromised identity can reach
  • Encrypted SSH command logging produces a per-session record of what was actually executed, which is essential for insider-threat investigation and for demonstrating privileged-session accountability to auditors
  • Because the target is reached through a Cloudflare Tunnel, the SSH port never needs to be exposed to the internet, removing it from the reach of credential-stuffing and scanning bots

Attack Prevented: Stolen or copied SSH private keys, persistent access after offboarding, lateral movement via shared keys, unaudited privileged sessions, internet-exposed SSH brute forcing

Prerequisites

  • A Cloudflare Tunnel connecting the target network (see section 5.1)
  • WARP deployed and enrolled on the client devices that will connect
  • An identity provider configured (see section 1.1)

ClickOps Implementation

Step 1: Route the Target Network Through a Tunnel

  1. Navigate to: Networking → Tunnels
  2. Select or create the tunnel serving the environment that hosts the SSH servers
  3. Navigate to: Networking → Routes, select Create route → Tunnel CIDR, choose the tunnel, and enter the CIDR range containing the target hosts

Step 2: Register Infrastructure Targets

  1. Navigate to: Zero Trust → Access controls → Targets
  2. Click Add a target
  3. Enter the target hostname, IP address, and the virtual network it belongs to
  4. Repeat for each server that should be reachable over SSH

Step 3: Create an Infrastructure Application

  1. Navigate to: Zero Trust → Access controls → Applications
  2. Click Add an application and select Infrastructure
  3. Enter an Application name, then under Target criteria match the targets by Target hostname (or Tag) and set Port to 22 with Protocol SSH
  4. Name the policy and, under Configure rules, include your IdP group (for example, Platform Engineering) and use Add require for login method and device posture
  5. Under Connection context, list the exact SSH user names the group may assume — avoid granting root where a named account will do

Step 4: Configure the Server to Trust the Cloudflare SSH CA

  1. Navigate to: Zero Trust → Access controls → Service credentials → SSH; if no account-wide CA exists, select Add a certificate and, under SSH with Access for Infrastructure, select Generate SSH CA; then open that certificate and copy its CA public key
  2. On each target host, install the CA public key and point TrustedUserCAKeys at it in the SSH daemon configuration
  3. Restart the SSH daemon and confirm certificate-based authentication succeeds
  4. Once verified, disable password authentication and remove distributed authorized_keys entries that are no longer needed

Step 5: Enable SSH Command Logging (L3)

  1. Generate an HPKE key pair with Cloudflare’s ssh-log-cli utility
  2. Navigate to: Zero Trust → Traffic controls → Traffic settings → SSH log encryption public key, select Edit, paste the public key, and select Save; keep the matching decryption key in your secrets manager
  3. Configure a Logpush job to deliver the encrypted session logs to your SIEM or object storage

Time to Complete: ~90 minutes for the first target, ~15 minutes per additional target

Validation & Testing

  1. Connect to a target as an authorized user and confirm the session succeeds without any local private key present
  2. Remove the test user from the IdP group and confirm a new connection attempt is denied while no long-lived key remains that would still work
  3. Attempt to connect as a Unix username not listed in the policy and confirm the session is refused
  4. Attempt to reach port 22 on the target directly from the public internet and confirm there is no listener
  5. Retrieve an encrypted session log, decrypt it with the stored private key, and confirm the executed commands are recorded

Compliance Mappings

Framework Control Requirement
CIS Controls v8 6.4 Require MFA for remote network access
CIS Controls v8 8.5 Collect detailed audit logs for privileged activity
NIST 800-53 Rev 5 AC-17 Remote access authorization, monitoring, and control
NIST 800-53 Rev 5 IA-5(2) Public key-based authentication
NIST 800-53 Rev 5 AU-14 Session audit and recording
SOC 2 CC6.6 Access to infrastructure is restricted to authorized personnel

Code Pack: Terraform
hth-cloudflare-2.04-access-for-infrastructure.tf View source on GitHub ↗
resource "cloudflare_zero_trust_access_infrastructure_target" "ssh_server" {
  account_id = var.cloudflare_account_id
  hostname   = var.ssh_target_hostname

  ip = {
    ipv4 = {
      ip_addr            = var.ssh_target_ip
      virtual_network_id = var.ssh_target_virtual_network_id
    }
  }
}

resource "cloudflare_zero_trust_access_application" "ssh_infrastructure" {
  account_id = var.cloudflare_account_id
  name       = "SSH infrastructure"
  type       = "infrastructure"

  target_criteria = [{
    port     = 22
    protocol = "SSH"
    target_attributes = {
      hostname = [var.ssh_target_hostname]
    }
  }]

  # Connection rules (allowed Unix usernames) are only accepted on a policy
  # defined inline on the infrastructure application
  policies = [{
    name       = "SSH - platform engineering"
    decision   = "allow"
    precedence = 1
    include = [{
      group = {
        id = var.ssh_allowed_group_id
      }
    }]
    connection_rules = {
      ssh = {
        usernames = var.ssh_allowed_usernames
      }
    }
  }]
}
Code Pack: API Script
hth-cloudflare-2.04-audit-infrastructure-access.sh View source on GitHub ↗
TARGETS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/infrastructure/targets") || {
  fail "2.4 Unable to list infrastructure targets"
  increment_failed
  summary
  exit 0
}
TARGET_COUNT=$(echo "${TARGETS}" | jq '.result | length')
info "2.4 Found ${TARGET_COUNT} infrastructure target(s)"

APPS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps") || {
  fail "2.4 Unable to retrieve Access applications"
  increment_failed
  summary
  exit 0
}

INFRA_COUNT=0
NO_POLICY=0
FETCH_ERR=0
while IFS= read -r app; do
  INFRA_COUNT=$((INFRA_COUNT + 1))
  APP_ID=$(echo "${app}" | jq -r '.id')
  APP_NAME=$(echo "${app}" | jq -r '.name')
  POLICIES=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps/${APP_ID}/policies") || {
    fail "2.4 Could not read policies for infrastructure application '${APP_NAME}'"
    FETCH_ERR=1
    continue
  }
  if [ "$(echo "${POLICIES}" | jq '.result | length')" = "0" ]; then
    warn "2.4 Infrastructure application '${APP_NAME}' has no Access policy"
    NO_POLICY=$((NO_POLICY + 1))
  fi
done < <(echo "${APPS}" | jq -c '.result[] | select(.type == "infrastructure")')

2.5 Gate Access Policies on User Risk Score

Profile Level: L3 (Run)

Framework Control
CIS Controls 13.1
NIST 800-53 AC-2(12)

Description

Enable Cloudflare’s behavioural risk scoring and use the resulting Low, Medium, or High score as a condition in Access policies, so that users exhibiting suspicious behaviour are blocked rather than continuing with the access their group membership would normally grant (Cloudflare user risk score documentation).

Rationale

Why This Matters:

  • Static policies evaluate identity and device at the moment of login, so an account that is compromised after enrolment keeps its access until someone notices and intervenes manually
  • Risk behaviours such as impossible travel, repeated DLP policy violations, and contact with known malware infrastructure are strong signals that an account or endpoint is under adversary control
  • Excluding High-risk users from Allow policies turns detection into enforcement automatically, closing the gap between an alert firing and an analyst responding
  • Every risk behaviour is disabled by default, so an organisation that assumes risk scoring is on out of the box is operating with no behavioural signal at all — the behaviours must be explicitly enabled to produce scores
  • Scores are per-user and visible in the dashboard, giving investigators a prioritised queue rather than an undifferentiated stream of Gateway and Access logs

Attack Prevented: Session hijacking and account takeover after initial login, insider data exfiltration, continued access by a compromised endpoint, credential sharing across geographies

Prerequisites

  • Cloudflare Zero Trust Enterprise plan
  • WARP deployed with Gateway logging enabled so behavioural signals are collected
  • Identity provider integration configured (see section 1.1)

ClickOps Implementation

Step 1: Enable Risk Behaviours

  1. Navigate to: Zero Trust → Team & Resources → Users → Risk score → Risk behaviors
  2. Review the available behaviours — all are disabled by default
  3. Enable the behaviours relevant to your environment, such as impossible travel, high number of DLP policy violations, and contact with known malware or command-and-control destinations
  4. Set the risk level each behaviour contributes (Low, Medium, or High) to match your tolerance

Step 2: Reference Risk Score in Access Policies

  1. Navigate to: Zero Trust → Access controls → Policies and edit the Allow policy of a sensitive application
  2. Add an Exclude rule with the User risk score selector set to High, so High-risk users no longer match the Allow policy
  3. Alternatively, add a separate Block policy with an Include rule on User risk score High and order it above the Allow policies

Step 3: Extend Risk Gating to Gateway (L3)

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies and add policies matching on user risk score
  2. Restrict high-risk users from reaching sensitive SaaS destinations or from uploading data

Step 4: Define the Response Runbook

  1. Document who reviews the risk score dashboard and how often
  2. Define the criteria for clearing a user’s risk score after investigation, and record who is authorised to clear it
  3. Feed risk score changes into your SIEM through Logpush so they correlate with other alerts

Time to Complete: ~60 minutes

Validation & Testing

  1. Confirm the behaviours you intended to enable show as enabled on the risk score page — verify rather than assume, since the default is off
  2. Trigger a test behaviour in a controlled way (for example, a deliberate DLP policy violation by a test account) and confirm the user’s score changes
  3. Attempt to reach a protected application as the elevated-risk test user and confirm access is denied
  4. Confirm the block appears in Access logs with the risk score as the reason
  5. Clear the test user’s risk score and confirm normal access is restored

Compliance Mappings

Framework Control Requirement
CIS Controls v8 13.1 Centralize security event alerting and response
NIST 800-53 Rev 5 AC-2(12) Account monitoring for atypical usage
NIST 800-53 Rev 5 SI-4 System monitoring and automated response
SOC 2 CC7.2 Anomalies are detected and evaluated

Code Pack: Terraform
hth-cloudflare-2.05-risk-behaviors.tf View source on GitHub ↗
data "cloudflare_zero_trust_risk_behavior" "current" {
  account_id = var.cloudflare_account_id
}

resource "cloudflare_zero_trust_risk_behavior" "enabled" {
  account_id = var.cloudflare_account_id

  behaviors = {
    for key, behavior in data.cloudflare_zero_trust_risk_behavior.current.behaviors :
    key => {
      enabled    = !contains(var.risk_behaviors_left_disabled, key)
      risk_level = behavior.risk_level
    }
  }
}
Code Pack: API Script
hth-cloudflare-2.05-audit-risk-behaviors.sh View source on GitHub ↗
BEHAVIORS=$(cf_get "/accounts/${CF_ACCOUNT_ID}/zt_risk_scoring/behaviors") || {
  fail "2.5 Unable to read risk behaviors (Enterprise plan required)"
  increment_failed
  summary
  exit 0
}
TOTAL=$(echo "${BEHAVIORS}" | jq '.result.behaviors // {} | length')
ENABLED=$(echo "${BEHAVIORS}" | jq '[.result.behaviors // {} | to_entries[] | select(.value.enabled == true)] | length')
echo "${BEHAVIORS}" | jq -r '.result.behaviors // {} | to_entries[]
  | "  - \(.value.name // .key): \(if .value.enabled then "enabled" else "disabled" end) (\(.value.risk_level))"'

# Access policies that act on a High user risk score
APPS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps") || {
  fail "2.5 Unable to retrieve Access applications"
  increment_failed
  summary
  exit 0
}
RISK_GATE_JQ='
def risk_levels(rules): [rules | .[]? | objects
  | (try .user_risk_score.user_risk_score catch null) // empty | .[]?];
[.result[] | (.decision // "allow") as $d
  | select(
      ($d == "allow" and (risk_levels(.exclude) | index("high")) != null)
      or ($d == "deny" and (risk_levels(.include) | index("high")) != null)
      or ($d == "allow" and (risk_levels(.require) | length) > 0
          and (risk_levels(.require) | index("high")) == null))
] | length'
GATED=0
FETCH_ERR=0
while IFS= read -r app_line; do
  APP_ID=$(echo "${app_line}" | jq -r '.id')
  APP_NAME=$(echo "${app_line}" | jq -r '.name // .id')
  POLICIES=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps/${APP_ID}/policies") || {
    fail "2.5 Could not read policies for application '${APP_NAME}'"
    FETCH_ERR=1
    continue
  }
  HITS=$(echo "${POLICIES}" | jq "${RISK_GATE_JQ}") || {
    fail "2.5 Could not parse policies for application '${APP_NAME}'"
    FETCH_ERR=1
    continue
  }
  if [ "${HITS}" -gt 0 ]; then
    info "2.5 Application '${APP_NAME}': ${HITS} policy(s) act on a High user risk score"
    GATED=$((GATED + 1))
  fi
done < <(echo "${APPS}" | jq -c '.result[]')

3. Gateway Security Policies

3.1 Configure DNS Filtering

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 9.2
NIST 800-53 SC-7, SI-3

Description

Configure Gateway DNS policies to block access to malicious and policy-violating domains.

Rationale

Why This Matters:

  • DNS filtering blocks threats at the resolution layer
  • Prevents access to malware, phishing, and C2 domains
  • Works for all traffic, not just HTTP(S)
  • Cloudflare’s threat intelligence provides real-time protection

Attack Prevented: Malware delivery, phishing, and command-and-control callbacks at the DNS resolution layer

ClickOps Implementation

Step 1: Create DNS Policy

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies → DNS
  2. Click Add a policy
  3. Configure blocking rules:

Step 2: Block Security Threats

  1. Create rule: “Block Security Threats”
  2. Configure:
    • Selector: Security Categories
    • Operator: in
    • Value: Malware, Phishing, Spyware, Command and Control & Botnet, Cryptomining, DNS Tunneling, DGA Domains, Brand Embedding
    • Action: Block
  3. Save

Step 3: Block Content Categories (Policy)

  1. Create additional rules with the Content Categories selector for policy enforcement:
    • Adult Themes
    • Gambling
    • Questionable Content (as appropriate)
  2. Configure action: Block

Time to Complete: ~30 minutes


Code Pack: Terraform
hth-cloudflare-3.01-configure-dns-filtering.tf View source on GitHub ↗
resource "cloudflare_zero_trust_gateway_policy" "block_security_threats_dns" {
  account_id = var.cloudflare_account_id
  name       = "Block Security Threats (DNS)"
  action     = "block"
  filters    = ["dns"]
  traffic    = "any(dns.security_category[*] in {80 83 117 131 153 175 176 178})"
  enabled    = true
  precedence = 10

  rule_settings = {
    block_page_enabled = true
    block_reason       = "Blocked: malware, phishing, spyware, or C2 domain"
  }
}

resource "cloudflare_zero_trust_gateway_policy" "block_content_categories_dns" {
  account_id = var.cloudflare_account_id
  name       = "Block Restricted Content Categories (DNS)"
  action     = "block"
  filters    = ["dns"]
  traffic    = "any(dns.content_category[*] in {67 125 133 99})"
  enabled    = true
  precedence = 20

  rule_settings = {
    block_page_enabled = true
    block_reason       = "This content category is blocked by policy"
  }
}
Code Pack: API Script
hth-cloudflare-3.01-configure-dns-filtering.sh View source on GitHub ↗
# Create Gateway DNS policy to block security threats
EXISTING=$(cf_get "/accounts/${CF_ACCOUNT_ID}/gateway/rules") || {
  fail "3.1 Unable to retrieve Gateway rules"
  increment_failed
  summary
  exit 0
}

# An existing rule counts only if it is an enabled DNS block on the
# security-category selector that covers Malware (117) and Phishing (131),
# read from the non-negated part of its expression (common.sh positive_traffic)
THREAT_RULES=$(echo "${EXISTING}" | jq "${JQ_GATEWAY_DEFS}"'[.result[]
  | select(.filters == ["dns"] and .action == "block" and .enabled == true)
  | positive_traffic
  | select(test("dns\\.security_category"))
  | select(test("\\b117\\b") and test("\\b131\\b"))] | length')

if [ "${THREAT_RULES}" -gt 0 ]; then
  pass "3.1 Found ${THREAT_RULES} DNS rule(s) blocking malware and phishing categories"
  increment_applied
  summary
  exit 0
fi

may_write "create the DNS rule 'HTH: Block Security Threats (DNS)'" || {
  fail "3.1 No enabled DNS rule blocks the malware and phishing security categories"
  increment_failed
  summary
  exit 0
}

info "3.1 Creating DNS security threat blocking rule..."
RESPONSE=$(cf_post "/accounts/${CF_ACCOUNT_ID}/gateway/rules" '{
  "name": "HTH: Block Security Threats (DNS)",
  "action": "block",
  "filters": ["dns"],
  "traffic": "any(dns.security_category[*] in {80 83 117 131 153 175 176 178})",
  "enabled": true,
  "precedence": 10,
  "rule_settings": {
    "block_page_enabled": true,
    "block_reason": "Blocked: malware, phishing, spyware, or C2 domain"
  }
}') || {
  fail "3.1 Failed to create DNS blocking rule"
  increment_failed
  summary
  exit 0
}
Code Pack: Sigma Detection Rule
hth-cloudflare-3.01-configure-dns-filtering.yml View source on GitHub ↗
detection:
    selection:
        ActionType|contains:
            - 'DeleteGatewayRule'
            - 'UpdateGatewayRule'
    filter_dns:
        Metadata|contains: 'dns'
    condition: selection and filter_dns
fields:
    - ActorEmail
    - ActionType
    - ResourceID
    - When

3.2 Configure HTTP Filtering

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 9.2, 13.3
NIST 800-53 SC-7, SI-4

Description

Configure Gateway HTTP policies for deeper inspection and control of web traffic.

Rationale

Why This Matters:

  • DNS filtering alone cannot see inside HTTP(S) sessions — Layer 7 inspection is needed to block malicious downloads and specific URLs
  • HTTP policies stop malware and botnet content even when delivered from otherwise-reputable or newly-categorized domains
  • Inline file inspection and antivirus scanning intercept malicious payloads before they reach the endpoint
  • Web-layer control reduces the chance that a single drive-by download or malicious file leads to endpoint compromise

Attack Prevented: Malware downloads, drive-by compromise, botnet communication, malicious file delivery

ClickOps Implementation

Prerequisite: TLS decryption must be on for HTTP policies to inspect HTTPS traffic (see 3.5, Step 1).

Step 1: Create HTTP Policy

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies → HTTP
  2. Click Add a policy

Step 2: Block Malicious Content

  1. Create rule: “Block Malware Downloads”
  2. Configure:
    • Selector: Security Categories
    • Operator: in
    • Value: Malware, Command and Control & Botnet, Phishing, Spyware
    • Action: Block

Step 3: Scan File Transfers for Malware (L2)

  1. Navigate to: Zero Trust → Traffic controls → Traffic settings
  2. Under Policy settings, turn on Scan files for malware and choose Scan on file upload and Scan on file download
  3. Optionally turn on Block requests for files that cannot be scanned (for example, encrypted archives) instead of allowing them through unscanned

Code Pack: Terraform
hth-cloudflare-3.02-configure-http-filtering.tf View source on GitHub ↗
resource "cloudflare_zero_trust_gateway_policy" "block_malware_http" {
  account_id = var.cloudflare_account_id
  name       = "Block Malware Downloads (HTTP)"
  action     = "block"
  filters    = ["http"]
  traffic    = "any(http.request.uri.security_category[*] in {80 117 131 153})"
  enabled    = true
  precedence = 10

  rule_settings = {
    block_page_enabled = true
    block_reason       = "Blocked: malware risk detected in download"
  }
}

check "gateway_inspection_enabled" {
  data "cloudflare_zero_trust_gateway_settings" "current" {
    account_id = var.cloudflare_account_id
  }

  assert {
    condition     = try(data.cloudflare_zero_trust_gateway_settings.current.settings.tls_decrypt.enabled, false) == true
    error_message = "TLS decryption is off: HTTP policies cannot inspect HTTPS traffic."
  }

  assert {
    condition     = try(data.cloudflare_zero_trust_gateway_settings.current.settings.antivirus.enabled_download_phase, false) == true
    error_message = "Antivirus scanning of downloads is off (Traffic settings > Scan files for malware)."
  }
}
Code Pack: API Script
hth-cloudflare-3.02-configure-http-filtering.sh View source on GitHub ↗
# Create Gateway HTTP policy to block malware downloads
EXISTING=$(cf_get "/accounts/${CF_ACCOUNT_ID}/gateway/rules") || {
  fail "3.2 Unable to retrieve Gateway rules"
  increment_failed
  summary
  exit 0
}

MALWARE_RULES=$(echo "${EXISTING}" | jq "${JQ_GATEWAY_DEFS}"'[.result[]
  | select(.filters == ["http"] and .action == "block" and .enabled == true)
  | positive_traffic
  | select(test("http\\.request\\.uri\\.security_category"))
  | select(test("\\b117\\b"))] | length')

if [ "${MALWARE_RULES}" -eq 0 ]; then
  may_write "create the HTTP rule 'HTH: Block Malware Downloads (HTTP)'" || {
    fail "3.2 No enabled HTTP rule blocks the malware security category"
    increment_failed
    summary
    exit 0
  }
  info "3.2 Creating HTTP malware blocking rule..."
  RESPONSE=$(cf_post "/accounts/${CF_ACCOUNT_ID}/gateway/rules" '{
    "name": "HTH: Block Malware Downloads (HTTP)",
    "action": "block",
    "filters": ["http"],
    "traffic": "any(http.request.uri.security_category[*] in {80 117 131 153})",
    "enabled": true,
    "precedence": 10,
    "rule_settings": {
      "block_page_enabled": true,
      "block_reason": "Blocked: malware risk detected in download"
    }
  }') || {
    fail "3.2 Failed to create HTTP blocking rule"
    increment_failed
    summary
    exit 0
  }
  if [ "$(echo "${RESPONSE}" | jq -r '.success')" != "true" ]; then
    fail "3.2 HTTP rule creation failed"
    echo "${RESPONSE}" | jq '.errors'
    increment_failed
    summary
    exit 0
  fi
  pass "3.2 HTTP malware blocking rule created"
else
  pass "3.2 Found ${MALWARE_RULES} HTTP rule(s) blocking the malware security category"
fi

3.3 Configure Network Policies

Profile Level: L2 (Walk)

Framework Control
CIS Controls 4.4, 13.4
NIST 800-53 SC-7, AC-4

Description

Configure Gateway network policies to control non-HTTP traffic based on IP, port, and protocol.

Rationale

Why This Matters:

  • Threats and data exfiltration frequently use non-HTTP channels that web and DNS filtering never inspect
  • Blocking risky ports, tunneling, and P2P protocols removes covert paths attackers use for command-and-control and lateral movement
  • Identity-based controls on the private network range (100.96.0.0/12) prevent any enrolled user from freely reaching internal systems
  • Logging private network access creates the audit trail needed to detect and investigate unauthorized internal connections

Attack Prevented: Command-and-control over non-HTTP ports, data exfiltration, lateral movement, unauthorized internal access

ClickOps Implementation

Prerequisite: Network policies apply only when Allow Secure Web Gateway to proxy traffic is on in Traffic controls → Traffic settings.

Step 1: Create Network Policy

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies → Network
  2. Click Add a policy

Step 2: Block Risky Protocols

  1. Create rules blocking:
    • Known malicious ports
    • Tunneling protocols (if not allowed)
    • P2P protocols
  2. Use the Detected Protocol selector to match protocols regardless of port
  3. Configure action: Block

Step 3: Control Private Network Access

  1. If using WARP-to-WARP (private network):
    • Create policies for 100.96.0.0/12 range
    • Restrict access by user identity
    • Log all private network access

Code Pack: Terraform
hth-cloudflare-3.03-configure-network-policies.tf View source on GitHub ↗
resource "cloudflare_zero_trust_gateway_policy" "block_risky_protocols" {
  account_id = var.cloudflare_account_id
  name       = "Block SSH to external hosts"
  action     = "block"
  filters    = ["l4"]
  traffic    = "net.dst.port == 22 and not(net.dst.ip in {10.0.0.0/8 172.16.0.0/12 192.168.0.0/16})"
  enabled    = true
  precedence = 10
}

resource "cloudflare_zero_trust_gateway_policy" "audit_rdp" {
  account_id = var.cloudflare_account_id
  name       = "Audit RDP connections"
  action     = "allow"
  filters    = ["l4"]
  traffic    = "net.dst.port == 3389"
  enabled    = true
  precedence = 20
}
Code Pack: API Script
hth-cloudflare-3.03-configure-network-policies.sh View source on GitHub ↗
# Create Gateway network policy to block risky protocols
EXISTING=$(cf_get "/accounts/${CF_ACCOUNT_ID}/gateway/rules") || {
  fail "3.3 Unable to retrieve Gateway rules"
  increment_failed
  summary
  exit 0
}

# Only the non-negated part of the expression counts (common.sh
# positive_traffic): not(net.dst.port == 22) blocks everything EXCEPT SSH.
SSH_RULES=$(echo "${EXISTING}" | jq "${JQ_GATEWAY_DEFS}"'[.result[]
  | select(.filters == ["l4"] and .action == "block" and .enabled == true)
  | positive_traffic
  | select(test("net\\.dst\\.port\\s*==\\s*22\\b")
      or test("net\\.dst\\.port\\s+in\\s*\\{[^}]*\\b22\\b[^}]*\\}"))] | length')

if [ "${SSH_RULES}" -gt 0 ]; then
  pass "3.3 Found ${SSH_RULES} network rule(s) blocking SSH (port 22)"
  increment_applied
  summary
  exit 0
fi

may_write "create the network rule 'HTH: Block External SSH'" || {
  fail "3.3 No enabled network rule blocks outbound SSH to external hosts"
  increment_failed
  summary
  exit 0
}

info "3.3 Creating network policy to block external SSH..."
RESPONSE=$(cf_post "/accounts/${CF_ACCOUNT_ID}/gateway/rules" '{
  "name": "HTH: Block External SSH",
  "action": "block",
  "filters": ["l4"],
  "traffic": "net.dst.port == 22 and not(net.dst.ip in {10.0.0.0/8 172.16.0.0/12 192.168.0.0/16})",
  "enabled": true,
  "precedence": 10
}') || {
  fail "3.3 Failed to create network blocking rule"
  increment_failed
  summary
  exit 0
}

3.4 Enable Browser Isolation (L3)

Profile Level: L3 (Run)

Framework Control
CIS Controls 10.5
NIST 800-53 SI-3

Description

Enable Cloudflare Browser Isolation to execute web sessions in a secure cloud environment, preventing malware execution on endpoints.

Rationale

Why This Matters:

  • Running web sessions in a remote cloud browser means active web code never executes on the endpoint, neutralizing browser-borne malware and zero-days
  • Isolating uncategorized and newly-registered domains contains the highest-risk browsing where threat intelligence has not yet caught up
  • Disabling copy/paste, printing, and uploads/downloads on sensitive sites prevents data from leaving controlled sessions
  • Isolation protects against exploit kits and malicious scripts even when users click links that slip past other filters

Attack Prevented: Browser-based malware, drive-by downloads, zero-day exploits, web-based data exfiltration

Prerequisites

  • Browser Isolation add-on license

ClickOps Implementation

Step 1: Create Isolation Policy

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies → HTTP
  2. Create rule with Action: Isolate
  3. Configure targets:
    • Content Categories in Security Risks (new, newly seen, and parked domains)
    • Security Categories for high-risk threat categories
    • Uncategorized content, where your policy allows

Step 2: Configure Isolation Settings

  1. In the same Isolate policy, open the policy settings (they are set per policy)
  2. Configure for sensitive sites:
    • Copy (from remote to client): Allow only within isolated browser
    • Paste (from client to remote): Do not allow
    • File downloads and File uploads: Do not allow
    • Printing: Do not allow

Code Pack: Terraform
hth-cloudflare-3.04-enable-browser-isolation.tf View source on GitHub ↗
resource "cloudflare_zero_trust_gateway_policy" "isolate_risky_sites" {
  account_id = var.cloudflare_account_id
  name       = "Isolate high-risk content"
  action     = "isolate"
  filters    = ["http"]
  traffic    = "any(http.request.uri.content_category[*] in {32 169 177 128})"
  enabled    = true
  precedence = 5

  rule_settings = {
    biso_admin_controls = {
      version  = "v2"
      copy     = "remote_only"
      paste    = "disabled"
      download = "disabled"
      upload   = "disabled"
      printing = "disabled"
      keyboard = "enabled"
    }
  }
}
Code Pack: API Script
hth-cloudflare-3.04-enable-browser-isolation.sh View source on GitHub ↗
# Create Gateway HTTP policy with isolate action for risky sites
EXISTING=$(cf_get "/accounts/${CF_ACCOUNT_ID}/gateway/rules") || {
  fail "3.4 Unable to retrieve Gateway rules"
  increment_failed
  summary
  exit 0
}

HARDENED_JQ='def one_of($vals): . as $v | $vals | index($v) != null;
def hardened:
  (.rule_settings.biso_admin_controls // {}) as $c
  | if $c.version == "v2" then
      (($c.copy // "enabled") | one_of(["remote_only", "disabled"]))
      and (($c.paste // "enabled") | one_of(["remote_only", "disabled"]))
      and (($c.download // "enabled") | one_of(["remote_only", "disabled"]))
      and ($c.upload == "disabled") and ($c.printing == "disabled")
    else
      ($c.dcp == true and $c.dd == true and $c.du == true and $c.dp == true)
    end;'
ISOLATE_RULES=$(echo "${EXISTING}" | jq '[.result[] | select(.action == "isolate" and .enabled == true)] | length')
HARDENED_RULES=$(echo "${EXISTING}" | jq "${HARDENED_JQ}"'
  [.result[] | select(.action == "isolate" and .enabled == true) | select(hardened)] | length')

if [ "${ISOLATE_RULES}" -gt 0 ] && [ "${HARDENED_RULES}" -gt 0 ]; then
  pass "3.4 ${ISOLATE_RULES} enabled browser isolation rule(s); ${HARDENED_RULES} block paste, downloads, uploads and printing"
  increment_applied
  summary
  exit 0
fi
if [ "${ISOLATE_RULES}" -gt 0 ]; then
  echo "${EXISTING}" | jq -r '.result[] | select(.action == "isolate" and .enabled == true) | "  - \(.name)"'
  fail "3.4 ${ISOLATE_RULES} enabled isolation rule(s), but none disables paste, downloads, uploads and printing (Step 2) -- set them in the rule's isolation settings"
  increment_failed
  summary
  exit 0
fi

may_write "create the HTTP rule 'HTH: Isolate High-Risk Content'" || {
  fail "3.4 No enabled browser isolation rule"
  increment_failed
  summary
  exit 0
}

info "3.4 Creating browser isolation policy for high-risk content..."
RESPONSE=$(cf_post "/accounts/${CF_ACCOUNT_ID}/gateway/rules" '{
  "name": "HTH: Isolate High-Risk Content",
  "action": "isolate",
  "filters": ["http"],
  "traffic": "any(http.request.uri.content_category[*] in {32 169 177 128})",
  "enabled": true,
  "precedence": 5,
  "rule_settings": {
    "biso_admin_controls": {
      "version": "v2",
      "copy": "remote_only",
      "paste": "disabled",
      "download": "disabled",
      "upload": "disabled",
      "printing": "disabled",
      "keyboard": "enabled"
    }
  }
}') || {
  fail "3.4 Failed to create browser isolation rule"
  increment_failed
  summary
  exit 0
}

3.5 Configure Gateway Data Loss Prevention Profiles

Profile Level: L2 (Walk)

Framework Control
CIS Controls 3.13
NIST 800-53 AC-4, SC-7(10)

Description

Use Gateway DLP profiles to inspect HTTP and SaaS traffic for sensitive data and block or log transfers that match. Two predefined profiles — Financial Information, and Social Security, Insurance, Tax, and Identifier Numbers — are available even on Free and Pay-as-you-go plans; custom profiles and additional detection entries require the Enterprise DLP add-on (Cloudflare data loss prevention documentation).

Rationale

Why This Matters:

  • Gateway HTTP filtering blocks what is coming in, but without DLP nothing inspects what is leaving — payment card numbers, national identifiers, and source code can be pasted into a personal cloud drive or an AI chat interface with no record and no control
  • Two predefined profiles are usable on Free and Pay-as-you-go plans, so the common assumption that DLP requires an Enterprise purchase leaves basic coverage unused at no additional cost
  • DLP policies match on the payload rather than the destination category, catching exfiltration to a newly registered or uncategorised domain that reputation-based filtering would allow
  • DLP detections feed the user risk score (see section 2.5), so a user repeatedly triggering DLP policies can be automatically escalated and blocked rather than merely logged
  • Running DLP in log-only mode first produces the evidence needed to tune profiles before enforcement, avoiding the false-positive backlash that causes teams to disable DLP entirely

Attack Prevented: Data exfiltration by insiders or compromised accounts, unintentional disclosure of regulated data to unsanctioned SaaS, sensitive data pasted into third-party AI or file-sharing services

Prerequisites

  • Gateway HTTP filtering enabled with TLS inspection configured (see section 3.2)
  • WARP deployed with the Cloudflare root certificate installed on managed devices

ClickOps Implementation

Step 1: Enable TLS Inspection

  1. Navigate to: Zero Trust → Traffic controls → Traffic settings
  2. In Proxy and inspection settings, turn on Inspect HTTPS requests with TLS decryption — DLP cannot inspect payloads inside encrypted sessions without it
  3. Confirm the Cloudflare root certificate is deployed to managed devices, and document any inspection bypasses required for banking or healthcare sites

Step 2: Review the Predefined Profiles

  1. Navigate to: Zero Trust → Data loss prevention → Profiles
  2. Open Financial Information and review its detection entries (payment card numbers and similar)
  3. Open Social Security, Insurance, Tax, and Identifier Numbers and review its entries
  4. Select Edit, turn on the detection entries you need, and under Settings set Match count greater than and Confidence threshold for the profile to reduce false positives

Step 3: Create a Log-Only HTTP Policy

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies → HTTP
  2. Add a policy with the DLP Profile selector set to the profiles enabled above
  3. Set Action to Allow with logging so matches are recorded without blocking
  4. Run for one to two weeks and review matches in Gateway HTTP logs

Step 4: Move to Enforcement

  1. After tuning, change the action to Block for the highest-confidence profiles
  2. Scope enforcement by destination where appropriate — for example, block uploads of matched data to personal file-sharing and unsanctioned AI services while allowing sanctioned applications
  3. Configure a custom block page explaining why the upload was stopped and how to request an exception

Step 5: Add Custom Profiles (L3, Enterprise DLP add-on)

  1. Navigate to: Zero Trust → Data loss prevention → Profiles → Create profile
  2. Define custom detection entries for organisation-specific identifiers such as customer account number formats or internal project codenames
  3. Apply the same log-then-enforce progression before blocking

Time to Complete: ~60 minutes to configure, plus one to two weeks of tuning

Validation & Testing

  1. From a WARP-enrolled test device, upload a file containing synthetic test data matching a predefined profile (use documented test values, never real customer data) and confirm the match appears in Gateway HTTP logs
  2. After enforcement is enabled, repeat the upload and confirm it is blocked and the block page is displayed
  3. Confirm that traffic on documented TLS inspection bypass lists is not inspected, and that this exposure is accepted and recorded
  4. Review one week of DLP matches and calculate the false-positive rate before widening enforcement
  5. Confirm DLP match events reach your SIEM through Logpush

Compliance Mappings

Framework Control Requirement
CIS Controls v8 3.13 Deploy a data loss prevention solution
NIST 800-53 Rev 5 AC-4 Information flow enforcement
NIST 800-53 Rev 5 SC-7(10) Prevent exfiltration of information
SOC 2 CC6.7 Transmission of sensitive data is restricted and monitored
PCI DSS v4.0 3.2 Limit storage and transmission of cardholder data

Code Pack: Terraform
hth-cloudflare-3.05-gateway-dlp.tf View source on GitHub ↗
resource "cloudflare_zero_trust_dlp_predefined_profile" "financial" {
  account_id           = var.cloudflare_account_id
  profile_id           = var.dlp_financial_profile_id
  allowed_match_count  = 1
  confidence_threshold = "medium"
}

resource "cloudflare_zero_trust_gateway_policy" "dlp_financial" {
  account_id = var.cloudflare_account_id
  name       = "DLP - Financial Information"
  action     = var.dlp_action
  filters    = ["http"]
  traffic    = "any(dlp.profiles[*] in {\"${cloudflare_zero_trust_dlp_predefined_profile.financial.profile_id}\"})"
  enabled    = true
  precedence = 30
}

check "dlp_tls_decryption_enabled" {
  data "cloudflare_zero_trust_gateway_settings" "dlp" {
    account_id = var.cloudflare_account_id
  }

  assert {
    condition     = try(data.cloudflare_zero_trust_gateway_settings.dlp.settings.tls_decrypt.enabled, false) == true
    error_message = "TLS decryption is off: DLP policies cannot inspect HTTPS payloads."
  }
}
Code Pack: API Script
hth-cloudflare-3.05-audit-gateway-dlp.sh View source on GitHub ↗
GW_CONFIG=$(cf_get "/accounts/${CF_ACCOUNT_ID}/gateway/configuration") || {
  fail "3.5 Unable to retrieve Gateway configuration"
  increment_failed
  summary
  exit 0
}
PROFILES=$(cf_get "/accounts/${CF_ACCOUNT_ID}/dlp/profiles") || {
  fail "3.5 Unable to list DLP profiles"
  increment_failed
  summary
  exit 0
}
RULES=$(cf_get "/accounts/${CF_ACCOUNT_ID}/gateway/rules") || {
  fail "3.5 Unable to retrieve Gateway rules"
  increment_failed
  summary
  exit 0
}

TLS_ON=$(echo "${GW_CONFIG}" | jq -r '.result.settings.tls_decrypt.enabled // false')
PROFILE_IDS=$(echo "${PROFILES}" | jq -c '[.result[].id]')
DLP_RULES=$(echo "${RULES}" | jq --argjson ids "${PROFILE_IDS}" "${JQ_GATEWAY_DEFS}"'[.result[]
  | select(.enabled == true and .action == "block" and (.filters | index("http")))
  | positive_traffic
  | select(test("dlp\\.profiles"))
  | select(. as $t | any($ids[]; . as $id | $t | contains($id)))] | length')
# DLP policies still in Allow (log-only) mode, e.g. the Terraform pack's default
LOG_ONLY=$(echo "${RULES}" | jq "${JQ_GATEWAY_DEFS}"'[.result[]
  | select(.enabled == true and .action == "allow" and (.filters | index("http")))
  | positive_traffic | select(test("dlp\\.profiles"))] | length')
info "3.5 DLP profiles: $(echo "${PROFILE_IDS}" | jq 'length'), enabled HTTP DLP block policies: ${DLP_RULES}, log-only (Allow) DLP policies: ${LOG_ONLY}"

4. WARP Client Hardening

4.1 Configure WARP Client Settings

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 4.1
NIST 800-53 CM-7, SC-7

Description

Configure WARP client settings to ensure consistent security posture across all enrolled devices.

Rationale

Why This Matters:

  • Consistent global settings ensure every enrolled device enforces the same Zero Trust protections rather than relying on per-user configuration
  • Auto-connect and captive-portal detection keep WARP active across reboots and untrusted WiFi, closing windows where traffic would bypass inspection
  • Locking the WARP switch prevents users from disabling protection to evade filtering or reach blocked content
  • A defined default service mode (Traffic and DNS mode) guarantees traffic is routed through inspection by default, not left to user choice

Attack Prevented: Protection bypass, unfiltered traffic on untrusted networks, inconsistent endpoint posture

ClickOps Implementation

The WARP client is now called the Cloudflare One Client (formerly WARP); its settings are configured per device profile.

Step 1: Open the Device Profile

  1. Navigate to: Zero Trust → Team & Resources → Devices → Device profiles → General profiles
  2. Select the profile (start with Default) and select Edit profile settings

Step 2: Configure Profile Settings

  1. Auto connect: Enabled, with a Timeout of 1-15 minutes — never 0, which lets a switched-off client stay off indefinitely
  2. Captive portal detection: Enabled (for WiFi networks)
  3. Service mode: Traffic and DNS mode
  4. Mode switch: Disabled, so users cannot drop to DNS only mode

Step 3: Configure Lock Settings (L2)

  1. Lock device client switch: Enabled (prevent user disable)
  2. Allow admin override codes: a global device client setting, available once the switch is locked (for troubleshooting)
  3. For trusted office networks, configure managed networks rather than letting users turn the client off

Code Pack: Terraform
hth-cloudflare-4.01-configure-warp-settings.tf View source on GitHub ↗
resource "cloudflare_zero_trust_device_default_profile" "default" {
  account_id        = var.cloudflare_account_id
  auto_connect      = var.warp_auto_connect_seconds
  captive_portal    = var.warp_captive_portal_seconds
  allow_mode_switch = false
  allow_updates     = true
  switch_locked     = true
  allowed_to_leave  = false

  service_mode_v2 = {
    mode = "warp"
  }

  # Split Tunnels in Exclude mode: only these destinations bypass Gateway
  exclude = [{
    address     = "10.0.0.0/8"
    description = "Internal RFC1918"
  }, {
    address     = "172.16.0.0/12"
    description = "Internal RFC1918"
  }, {
    address     = "192.168.0.0/16"
    description = "Internal RFC1918"
  }]
}
Code Pack: API Script
hth-cloudflare-4.01-configure-warp-settings.sh View source on GitHub ↗
# Check the default device profile; fix only out-of-range fields
CURRENT=$(cf_get "/accounts/${CF_ACCOUNT_ID}/devices/policy") || {
  fail "4.1 Unable to retrieve device policy"
  increment_failed
  summary
  exit 0
}

info "4.1 Current: $(echo "${CURRENT}" | jq -r '.result
  | "auto_connect=\(.auto_connect // "unset"), captive_portal=\(.captive_portal // "unset"), allow_mode_switch=\(.allow_mode_switch)"')"

# Fields to write, each set to its hardened value only when out of range
CHANGES=$(echo "${CURRENT}" | jq -c '.result as $c | {}
  + (if ($c.auto_connect | type) != "number" or $c.auto_connect <= 0 or $c.auto_connect > 900
     then {auto_connect: 900} else {} end)
  + (if ($c.captive_portal | type) != "number" or $c.captive_portal <= 0
     then {captive_portal: 180} else {} end)
  + (if $c.allow_mode_switch != false then {allow_mode_switch: false} else {} end)')
# Below the guide's 1-minute floor: out of range, but on the stricter side
TOO_SHORT=$(echo "${CURRENT}" | jq -r '.result.auto_connect as $a
  | if ($a | type) == "number" and $a > 0 and $a < 60 then "true" else "false" end')

if [ "${CHANGES}" = "{}" ] && [ "${TOO_SHORT}" = "false" ]; then
  pass "4.1 Default device profile is within the hardened settings"
  increment_applied
  summary
  exit 0
fi

if [ "${TOO_SHORT}" = "true" ]; then
  warn "4.1 auto_connect is below 60 seconds -- users may not finish captive-portal logins; set a Timeout of 1-15 minutes in the dashboard (not raised here: that would lengthen the off window)"
fi
if [ "${CHANGES}" = "{}" ]; then
  fail "4.1 auto_connect is outside the guide's 1-15 minute range"
  increment_failed
  summary
  exit 0
fi

info "4.1 Fields outside the hardened settings:"
echo "${CHANGES}" | jq -r 'to_entries[] | "  - \(.key) -> \(.value)"'

may_write "update the default device profile" || {
  fail "4.1 Default device profile is not hardened"
  increment_failed
  summary
  exit 0
}

RESPONSE=$(cf_patch "/accounts/${CF_ACCOUNT_ID}/devices/policy" "${CHANGES}") || {
  fail "4.1 Failed to update device policy"
  increment_failed
  summary
  exit 0
}

4.2 Lock WARP Client

Profile Level: L2 (Walk)

Framework Control
CIS Controls 4.1
NIST 800-53 CM-7

Description

Lock WARP client to prevent users from disabling Zero Trust protection.

Rationale

Why This Matters:

  • If users can freely disable WARP, all Gateway filtering and Access posture checks can be bypassed at will, defeating the Zero Trust model
  • Locking the switch keeps every device continuously inspected, including when malware or a user actively tries to evade controls
  • Admin override codes preserve a controlled, time-limited path for legitimate troubleshooting without leaving the switch open to everyone
  • Enforcing Traffic and DNS mode ensures all traffic remains filtered rather than silently falling back to an unprotected path

Attack Prevented: Security-control evasion, unfiltered malicious traffic, posture-check bypass

ClickOps Implementation

Step 1: Enable Lock Settings

  1. Navigate to: Zero Trust → Team & Resources → Devices → Device profiles → General profiles
  2. Select the device profile and select Edit profile settings
  3. Enable Lock device client switch

Step 2: Configure Override Codes (Optional)

  1. Enable Allow admin override codes (a global device client setting; it requires Lock device client switch)
  2. Admins can generate temporary disable codes
  3. Codes can be time-limited

Step 3: Configure Service Mode

  1. Set Service mode: Traffic and DNS mode
  2. This ensures all traffic is filtered
  3. Alternative modes available for specific needs

Code Pack: Terraform
hth-cloudflare-4.02-lock-warp-client.tf View source on GitHub ↗
check "warp_client_locked" {
  data "cloudflare_zero_trust_device_default_profile" "current" {
    account_id = var.cloudflare_account_id
  }

  assert {
    condition     = data.cloudflare_zero_trust_device_default_profile.current.switch_locked == true
    error_message = "Lock device client switch is off: users can turn the client off."
  }

  assert {
    condition     = data.cloudflare_zero_trust_device_default_profile.current.allowed_to_leave == false
    error_message = "Users are allowed to leave the Zero Trust organization from the client."
  }

  # The guide's Timeout of 1-15 minutes, in seconds; 0 lets a switched-off
  # client stay off indefinitely. The same range as api pack 4.01.
  assert {
    condition = (
      data.cloudflare_zero_trust_device_default_profile.current.auto_connect >= 60 &&
      data.cloudflare_zero_trust_device_default_profile.current.auto_connect <= 900
    )
    error_message = "Auto connect is outside 60-900 seconds (the guide's 1-15 minute Timeout); 0 lets a switched-off client stay off indefinitely."
  }
}
Code Pack: API Script
hth-cloudflare-4.02-lock-warp-client.sh View source on GitHub ↗
# Lock WARP client to prevent users from disabling
info "4.2 Locking WARP client..."
RESPONSE=$(cf_patch "/accounts/${CF_ACCOUNT_ID}/devices/policy" '{
  "switch_locked": true,
  "allowed_to_leave": false,
  "allow_mode_switch": false
}') || {
  fail "4.2 Failed to lock WARP client"
  increment_failed
  summary
  exit 0
}
Code Pack: Sigma Detection Rule
hth-cloudflare-4.02-lock-warp-client.yml View source on GitHub ↗
detection:
    selection:
        ActionType: 'UpdateDevicePolicy'
    filter_unlock:
        Metadata|contains:
            - 'switch_locked'
            - 'allowed_to_leave'
    condition: selection and filter_unlock
fields:
    - ActorEmail
    - ActionType
    - ResourceID
    - When

4.3 Configure Split Tunnel Settings

Profile Level: L2 (Walk)

Framework Control
CIS Controls 13.5
NIST 800-53 SC-7

Description

Configure split tunnel settings to control which traffic passes through WARP and which bypasses.

Rationale

Why This Matters:

  • By default, the client runs in Exclude mode: all traffic goes to Gateway except a small default exclusion list
  • Split tunnel can improve performance for specific apps
  • Excessive split tunnel reduces security visibility
  • Document all exceptions with business justification

Attack Prevented: Data exfiltration and threats hidden in traffic bypassing WARP inspection via excessive split-tunnel exceptions

ClickOps Implementation

Step 1: Access Split Tunnel Settings

  1. Navigate to: Zero Trust → Team & Resources → Devices → Device profiles → General profiles
  2. Select the device profile and select Edit profile settings
  3. Scroll to Split Tunnels and select Manage

Step 2: Configure Minimum Exceptions

  1. Mode: Exclude IPs and domains (the default)
  2. Add only necessary exceptions:
    • Video conferencing (Zoom, Teams IPs)
    • Local network access (RFC1918)
  3. Document each exception

Step 3: Avoid Include Mode for General Users (L3)

  1. Include mode sends only the listed destinations to Gateway
  2. All other traffic bypasses Gateway and is no longer filtered by your DNS, HTTP, or network policies
  3. Keep Exclude mode and minimize exclusions; reserve Include mode for narrow use cases such as private-network-only access

Code Pack: Terraform
hth-cloudflare-4.03-configure-split-tunnels.tf View source on GitHub ↗
check "split_tunnel_exclude_mode" {
  data "cloudflare_zero_trust_device_default_profile" "split" {
    account_id = var.cloudflare_account_id
  }

  assert {
    condition     = length(coalesce(data.cloudflare_zero_trust_device_default_profile.split.include, [])) == 0
    error_message = "Split Tunnels is in Include mode: traffic not listed bypasses Gateway."
  }

  assert {
    condition = alltrue([
      for route in coalesce(data.cloudflare_zero_trust_device_default_profile.split.exclude, []) :
      try(route.description, "") != ""
    ])
    error_message = "Every Split Tunnel exclusion needs a description recording its business justification."
  }
}
Code Pack: API Script
hth-cloudflare-4.03-configure-split-tunnels.sh View source on GitHub ↗
# Audit split tunnel mode and exclusions for the default and custom profiles
FETCH_ERR=0
audit_profile "default profile" \
  "/accounts/${CF_ACCOUNT_ID}/devices/policy/include" \
  "/accounts/${CF_ACCOUNT_ID}/devices/policy/exclude" || FETCH_ERR=1

# Parse the profile list before looping: a jq failure inside `done < <(jq ...)`
# is silent and would leave the loop empty, so custom profiles would go unaudited
PROFILE_LINES=""
if PROFILES=$(cf_get "/accounts/${CF_ACCOUNT_ID}/devices/policies"); then
  PROFILE_LINES=$(echo "${PROFILES}" | jq -c "${RESULT_LIST} | .[]
    | select(.default != true and .policy_id != null)") || {
    fail "4.3 Unable to parse the custom device profile list"
    FETCH_ERR=1
    PROFILE_LINES=""
  }
else
  fail "4.3 Unable to list custom device profiles"
  FETCH_ERR=1
fi
while IFS= read -r profile; do
  [ -z "${profile}" ] && continue
  PID=$(echo "${profile}" | jq -r '.policy_id')
  PNAME=$(echo "${profile}" | jq -r '.name // .policy_id')
  audit_profile "profile '${PNAME}'" \
    "/accounts/${CF_ACCOUNT_ID}/devices/policy/${PID}/include" \
    "/accounts/${CF_ACCOUNT_ID}/devices/policy/${PID}/exclude" || FETCH_ERR=1
done < <(printf '%s\n' "${PROFILE_LINES}")

5. Tunnel Security

5.1 Secure Cloudflare Tunnel Configuration

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 12.1
NIST 800-53 SC-7, SC-8

Description

Configure Cloudflare Tunnel (formerly Argo Tunnel) securely to expose internal applications without opening inbound ports.

Rationale

Why This Matters:

  • Tunnels eliminate inbound firewall rules
  • Misconfigured tunnels can expose internal services
  • Access policies must protect tunnel endpoints
  • Tunnel credentials must be secured

Attack Prevented: Internal service exposure via misconfigured tunnels, tunnel credential theft, unauthenticated access to tunnel endpoints

ClickOps Implementation

Step 1: Create Tunnel

  1. Navigate to: Networking → Tunnels
  2. Click Create Tunnel
  3. Enter a descriptive Tunnel name
  4. Install cloudflared on origin server

Step 2: Publish the Application Route

  1. Select the tunnel → Routes → Add route → Published application
  2. Configure:
    • Subdomain and domain: app.yourdomain.com
    • Service URL: http://localhost:8080
  3. Create the Access application first (5.2)

Step 3: Secure Tunnel Credentials

  1. Tunnel token should be treated as secret
  2. Store securely (vault, secrets manager)
  3. Rotate if compromised

Code Pack: Terraform
hth-cloudflare-5.01-secure-tunnel-config.tf View source on GitHub ↗
resource "random_id" "tunnel_secret" {
  byte_length = 35
}

resource "cloudflare_zero_trust_tunnel_cloudflared" "app_tunnel" {
  account_id    = var.cloudflare_account_id
  name          = "app-tunnel"
  config_src    = "cloudflare"
  tunnel_secret = random_id.tunnel_secret.b64_std
}

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "app_tunnel_config" {
  account_id = var.cloudflare_account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.app_tunnel.id

  config = {
    ingress = [{
      hostname = var.app_domain
      service  = var.app_origin_url

      origin_request = {
        connect_timeout = 10
        no_tls_verify   = false
      }
    }, {
      service = "http_status:404"
    }]
  }
}

resource "cloudflare_dns_record" "tunnel_cname" {
  zone_id = var.cloudflare_zone_id
  name    = var.app_subdomain
  type    = "CNAME"
  content = "${cloudflare_zero_trust_tunnel_cloudflared.app_tunnel.id}.cfargotunnel.com"
  proxied = true
  ttl     = 1 # automatic
}
Code Pack: API Script
hth-cloudflare-5.01-secure-tunnel-config.sh View source on GitHub ↗
# List all tunnels and check configuration
TUNNELS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?is_deleted=false") || {
  fail "5.1 Unable to retrieve tunnel list"
  increment_failed
  summary
  exit 0
}

TUNNEL_COUNT=$(echo "${TUNNELS}" | jq '.result | length')
info "5.1 Found ${TUNNEL_COUNT} active tunnel(s)"

FLAGGED=0
while IFS= read -r tunnel; do
  TUNNEL_NAME=$(echo "${tunnel}" | jq -r '.name')
  TUNNEL_STATUS=$(echo "${tunnel}" | jq -r '.status // "unknown"')
  REMOTE_CONFIG=$(echo "${tunnel}" | jq -r 'if .remote_config == true or .config_src == "cloudflare" then "true" else "false" end')
  CONNS=$(echo "${tunnel}" | jq '.connections // [] | length')

  info "5.1 Tunnel '${TUNNEL_NAME}': status=${TUNNEL_STATUS}, connections=${CONNS}"
  if [ "${REMOTE_CONFIG}" != "true" ]; then
    warn "5.1 Tunnel '${TUNNEL_NAME}' is locally managed -- its ingress rules cannot be audited here"
    FLAGGED=$((FLAGGED + 1))
  fi
  if [ "${TUNNEL_STATUS}" != "healthy" ]; then
    warn "5.1 Tunnel '${TUNNEL_NAME}' is ${TUNNEL_STATUS} -- remove unused tunnels and their credentials"
    FLAGGED=$((FLAGGED + 1))
  fi
done < <(echo "${TUNNELS}" | jq -c '.result[]')
Code Pack: CLI Script
hth-cloudflare-5.01-list-tunnels.sh View source on GitHub ↗
# List active tunnels as JSON and flag any with no live connections
TUNNELS=$(cloudflared tunnel list --output json) || {
  echo "[FAIL] 5.1 cloudflared tunnel list failed (run 'cloudflared tunnel login' or set TUNNEL_ORIGIN_CERT)"
  exit 1
}

# Anything but a JSON list of tunnel objects means the listing cannot be trusted
COUNT=$(echo "${TUNNELS}" | jq 'if type == "array" and all(.[]; type == "object")
  then length else error("not a list of tunnels") end' 2>/dev/null) || {
  echo "[FAIL] 5.1 cloudflared tunnel list did not return a JSON list of tunnels"
  exit 1
}
if [ "${COUNT}" -eq 0 ]; then
  echo "[SKIP] 5.1 No tunnels in this account -- nothing was checked"
  exit 0
fi

echo "${TUNNELS}" | jq -r '.[] | "  - \(.name) (\(.id)): \((.connections // []) | length) connection(s)"'
IDLE=$(echo "${TUNNELS}" | jq '[.[] | select(((.connections // []) | length) == 0)] | length')
Code Pack: Sigma Detection Rule
hth-cloudflare-5.01-secure-tunnel-config.yml View source on GitHub ↗
detection:
    selection:
        ActionType|contains:
            - 'CreateTunnel'
            - 'UpdateTunnel'
            - 'DeleteTunnel'
            - 'UpdateTunnelConfiguration'
    condition: selection
fields:
    - ActorEmail
    - ActionType
    - ResourceID
    - When

5.2 Protect Tunnels with Access Policies

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.4
NIST 800-53 AC-3

Description

Always protect tunnel endpoints with Access policies before exposing them publicly.

Rationale

Why This Matters:

  • A tunnel hostname published without an Access policy exposes the internal application directly to the entire internet
  • Creating the Access application first ensures the endpoint is never reachable during the window between publishing and securing it
  • Identity-based Access policies require authenticated, authorized users before any request reaches the origin service
  • Unprotected tunnels are quickly discovered by automated scanners, making an Access gate the difference between private and publicly exploitable

Attack Prevented: Unauthenticated access to internal apps, exposure of internal services, automated scanning and exploitation

ClickOps Implementation

Step 1: Create Access Application First

  1. Navigate to: Zero Trust → Access controls → Applications → Add an application → Self-hosted and private for the hostname
  2. Configure appropriate access policy
  3. Test policy with test users

Step 2: Then Configure Tunnel

  1. Navigate to: Networking → Tunnels, select the tunnel → Routes → Add route → Published application with the same hostname
  2. Point to internal service
  3. Access policy automatically protects endpoint

Never expose tunnel endpoints without Access protection.


Code Pack: Terraform
hth-cloudflare-5.02-protect-tunnels-with-access.tf View source on GitHub ↗
resource "cloudflare_zero_trust_access_application" "tunnel_app" {
  zone_id          = var.cloudflare_zone_id
  name             = "Tunnel-Protected Application"
  domain           = var.app_domain
  type             = "self_hosted"
  session_duration = "8h"

  allowed_idps              = var.allowed_idp_ids
  auto_redirect_to_identity = true

  policies = [{
    id         = cloudflare_zero_trust_access_policy.tunnel_app_policy.id
    precedence = 1
  }]
}

resource "cloudflare_zero_trust_access_policy" "tunnel_app_policy" {
  account_id = var.cloudflare_account_id
  name       = "Allow authenticated employees via tunnel"
  decision   = "allow"

  include = [{
    group = {
      id = var.employees_group_id
    }
  }]

  require = [{
    auth_method = {
      auth_method = "mfa"
    }
  }]

  session_duration = "8h"
}
Code Pack: API Script
hth-cloudflare-5.02-protect-tunnels-with-access.sh View source on GitHub ↗
# Cross-reference tunnel hostnames with enforcing Access applications
TUNNELS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?is_deleted=false") || {
  fail "5.2 Unable to retrieve tunnels"
  increment_failed
  summary
  exit 0
}

APPS=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps") || {
  fail "5.2 Unable to retrieve Access applications"
  increment_failed
  summary
  exit 0
}

# Every whole-hostname pattern an application covers: its host as an anchored
# regex ("*" = one label's worth of characters) and its specificity (the
# number of literal characters). Path-scoped entries do not cover a hostname.
APP_COVER=$(echo "${APPS}" | jq -c '[.result[] | {id, name} as $a
  | (.domain, .self_hosted_domains[]?, (.destinations[]? | .uri?))
  | select(type == "string" and . != "") | ascii_downcase
  | (sub("/.*$"; "")) as $h
  | (if test("/") then sub("^[^/]*"; "") else "" end) as $p
  | select(($p == "" or $p == "/*") and ($h | test("^[a-z0-9.*-]+$")))
  | {id: $a.id, name: ($a.name // $a.id),
     re: ("^" + ($h | gsub("\\."; "\\.") | gsub("\\*"; "[^.]*")) + "$"),
     spec: ($h | gsub("\\*"; "") | length)}]')

# app_status <app-id>: sets APP_STATUS to enforced | bypass-everyone |
# bypass-only | no-policy | unreadable. Called as a plain statement (not in
# $(...)) so the cache survives and each application's policies are read once.
STATUS_CACHE=""
APP_STATUS=""
app_status() {
  local id=$1 pol
  APP_STATUS=$(printf '%s\n' "${STATUS_CACHE}" | awk -v id="${id}" '$1 == id { print $2; exit }')
  [ -n "${APP_STATUS}" ] && return 0
  if pol=$(cf_get_all "/accounts/${CF_ACCOUNT_ID}/access/apps/${id}/policies"); then
    APP_STATUS=$(echo "${pol}" | jq -r '.result as $p
      | if ($p | length) == 0 then "no-policy"
        elif any($p[]; .decision == "bypass" and any(.include[]?; has("everyone"))) then "bypass-everyone"
        elif (any($p[]; (.decision // "allow") != "bypass") | not) then "bypass-only"
        else "enforced" end') || APP_STATUS=unreadable
  else
    APP_STATUS=unreadable
  fi
  STATUS_CACHE="${STATUS_CACHE}
${id} ${APP_STATUS}"
}

CHECKED=0
UNPROTECTED=0
UNVERIFIED=0
while IFS= read -r tunnel; do
  TUNNEL_NAME=$(echo "${tunnel}" | jq -r '.name')
  TUNNEL_ID=$(echo "${tunnel}" | jq -r '.id')

  CONFIG=$(cf_get "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations") || {
    warn "5.2 Tunnel '${TUNNEL_NAME}': configuration not readable -- UNVERIFIED"
    UNVERIFIED=$((UNVERIFIED + 1))
    continue
  }
  # A locally managed tunnel keeps its ingress rules in a file on the origin
  if [ "$(echo "${CONFIG}" | jq -r 'if .result.source == "local" or (.result.config | type) != "object" then "no" else "yes" end')" != "yes" ]; then
    warn "5.2 Tunnel '${TUNNEL_NAME}': locally managed, ingress rules not in the API -- UNVERIFIED"
    UNVERIFIED=$((UNVERIFIED + 1))
    continue
  fi
  # Parse before looping: a jq failure inside `done < <(jq ...)` is silent
  HOSTNAMES=$(echo "${CONFIG}" | jq -r '(.result.config.ingress // [])
    | if type == "array" then .[] | (.hostname // empty) | ascii_downcase
      else error("ingress is not a list") end') || {
    warn "5.2 Tunnel '${TUNNEL_NAME}': configuration could not be parsed -- UNVERIFIED"
    UNVERIFIED=$((UNVERIFIED + 1))
    continue
  }

  while IFS= read -r hostname; do
    [ -z "${hostname}" ] && continue
    CHECKED=$((CHECKED + 1))
    # The most specific application(s) covering this hostname
    DECIDING=$(echo "${APP_COVER}" | jq -r --arg h "${hostname}" '[.[] | . as $e | select($h | test($e.re))]
      | (map(.spec) | max) as $m | [.[] | select(.spec == $m)] | unique_by(.id) | .[] | "\(.id)\t\(.name)"') || {
      warn "5.2 Tunnel '${TUNNEL_NAME}' hostname '${hostname}': Access coverage could not be evaluated -- UNVERIFIED"
      UNVERIFIED=$((UNVERIFIED + 1))
      continue
    }
    if [ -z "${DECIDING}" ]; then
      warn "5.2 Tunnel '${TUNNEL_NAME}' hostname '${hostname}' has NO Access application"
      UNPROTECTED=$((UNPROTECTED + 1))
      continue
    fi
    VERDICT=enforced
    BAD_APP=""
    while IFS=$'\t' read -r app_id app_name; do
      app_status "${app_id}"
      case "${APP_STATUS}" in
        enforced) ;;
        unreadable) [ "${VERDICT}" = "enforced" ] && VERDICT=unreadable ;;
        *) VERDICT="${APP_STATUS}"; BAD_APP="${app_name}" ;;
      esac
    done < <(printf '%s\n' "${DECIDING}")
    case "${VERDICT}" in
      enforced)
        info "5.2 Tunnel '${TUNNEL_NAME}' hostname '${hostname}' has Access protection" ;;
      unreadable)
        warn "5.2 Tunnel '${TUNNEL_NAME}' hostname '${hostname}': policies of its Access application could not be read -- UNVERIFIED"
        UNVERIFIED=$((UNVERIFIED + 1)) ;;
      *)
        warn "5.2 Tunnel '${TUNNEL_NAME}' hostname '${hostname}': Access application '${BAD_APP}' does not enforce Access (${VERDICT})"
        UNPROTECTED=$((UNPROTECTED + 1)) ;;
    esac
  done < <(printf '%s\n' "${HOSTNAMES}")
done < <(echo "${TUNNELS}" | jq -c '.result[]')

5.3 Detect and Block TryCloudflare Quick Tunnel Abuse

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 4.8, 13.3
NIST 800-53 CM-7(2), SI-4

Description

TryCloudflare quick tunnels create an ephemeral *.trycloudflare.com hostname without requiring a Cloudflare account. Proofpoint has tracked threat actors abusing this since February 2024 to stage and deliver remote access trojans through what looks like legitimate Cloudflare infrastructure. Block outbound access to trycloudflare.com through Gateway and alert on unauthorized cloudflared execution on managed endpoints (Proofpoint threat research).

Rationale

Why This Matters:

  • Quick tunnels require no Cloudflare account, no domain, and no payment, so an attacker can stand up a delivery or command-and-control endpoint in seconds with no attributable registration trail
  • The resulting hostname sits on a Cloudflare domain with a valid TLS certificate, so reputation and category filtering that would flag a newly registered domain often lets the traffic through
  • Proofpoint has observed campaigns using these tunnels to deliver AsyncRAT, Xworm, VenomRAT, and similar remote access trojans through malicious LNK and shortcut files that fetch payloads over the tunnel
  • The same technique works in reverse for exfiltration and unauthorized remote access: an insider or an attacker who lands on an endpoint can run cloudflared to expose an internal service outbound, bypassing every inbound firewall rule
  • Because your organisation almost certainly uses named, authenticated tunnels (section 5.1) rather than anonymous quick tunnels, blocking the quick tunnel domain outright costs nothing operationally while removing a live abuse channel

Attack Prevented: RAT delivery over trusted infrastructure, command-and-control tunnelling, unauthorized outbound exposure of internal services, data exfiltration through anonymous tunnels

ClickOps Implementation

Step 1: Block the Quick Tunnel Domain in Gateway DNS

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies → DNS
  2. Click Add a policy and name it “Block TryCloudflare Quick Tunnels”
  3. Configure the rule:
    • Selector: Domain
    • Operator: is
    • Value: trycloudflare.com
  4. Set Action to Block and save
  5. Confirm the policy is ordered so no broader allow rule precedes it

Step 2: Add an HTTP Policy for Defence in Depth

  1. Navigate to: Zero Trust → Traffic controls → Firewall policies → HTTP
  2. Add a policy with the Domain selector set to trycloudflare.com (the Domain selector matches the domain and all of its subdomains; the Host selector matches one exact hostname only)
  3. Set Action to Block so requests that bypass DNS resolution are still stopped

Step 3: Restrict cloudflared Execution on Endpoints

  1. In your endpoint management or EDR platform, create an application control rule permitting cloudflared to run only on the servers where a named tunnel is intentionally deployed
  2. Alert on any cloudflared process starting on a user workstation
  3. Alert on command lines containing quick tunnel invocation flags, since a legitimate named tunnel is run as a service with a credentials file rather than an ad-hoc URL flag

Step 4: Hunt for Existing Abuse

  1. Review Gateway DNS and HTTP logs for historical resolutions of trycloudflare.com before the block was applied
  2. Review endpoint telemetry for cloudflared binaries in user-writable directories such as download and temporary folders
  3. Investigate any LNK or shortcut file execution that preceded a quick tunnel connection, which matches the documented delivery chain

Step 5: Sanction the Legitimate Path

  1. Confirm every business-justified tunnel is a named tunnel owned by the account and protected by an Access policy (see sections 5.1 and 5.2)
  2. Document the exception process for developers who previously used quick tunnels for local testing, and point them at named tunnels instead

Time to Complete: ~30 minutes for blocking, plus hunting effort

Validation & Testing

  1. From a WARP-enrolled test device, attempt to resolve and reach a trycloudflare.com hostname and confirm the Gateway block page or NXDOMAIN response is returned
  2. Confirm the block event appears in Gateway DNS logs with the correct policy name
  3. Run cloudflared on a test workstation and confirm the endpoint alert fires within your expected detection window
  4. Verify that named tunnels serving production applications continue to function and were not affected by the block
  5. Re-run the historical log hunt after 30 days to confirm no further quick tunnel activity

Compliance Mappings

Framework Control Requirement
CIS Controls v8 4.8 Uninstall or disable unnecessary services on enterprise assets
CIS Controls v8 13.3 Deploy network intrusion detection
NIST 800-53 Rev 5 CM-7(2) Prevent program execution contrary to policy
NIST 800-53 Rev 5 SI-4 System monitoring for unauthorized connections
SOC 2 CC7.2 Anomalous network activity is detected and evaluated

Code Pack: Terraform
hth-cloudflare-5.03-block-trycloudflare.tf View source on GitHub ↗
resource "cloudflare_zero_trust_gateway_policy" "block_trycloudflare_dns" {
  account_id = var.cloudflare_account_id
  name       = "Block TryCloudflare Quick Tunnels (DNS)"
  action     = "block"
  filters    = ["dns"]
  traffic    = "any(dns.domains[*] == \"trycloudflare.com\")"
  enabled    = true
  precedence = 6
}

resource "cloudflare_zero_trust_gateway_policy" "block_trycloudflare_http" {
  account_id = var.cloudflare_account_id
  name       = "Block TryCloudflare Quick Tunnels (HTTP)"
  action     = "block"
  filters    = ["http"]
  traffic    = "any(http.request.domains[*] == \"trycloudflare.com\")"
  enabled    = true
  precedence = 7
}
Code Pack: API Script
hth-cloudflare-5.03-audit-trycloudflare-block.sh View source on GitHub ↗
RULES=$(cf_get "/accounts/${CF_ACCOUNT_ID}/gateway/rules") || {
  fail "5.3 Unable to retrieve Gateway rules"
  increment_failed
  summary
  exit 0
}
DNS_BLOCKS=$(echo "${RULES}" | jq "${JQ_GATEWAY_DEFS}"'[.result[]
  | select(.enabled == true and .action == "block" and (.filters | index("dns")))
  | positive_traffic
  | select(test("trycloudflare\\.com"))] | length')
HTTP_BLOCKS=$(echo "${RULES}" | jq "${JQ_GATEWAY_DEFS}"'[.result[]
  | select(.enabled == true and .action == "block" and (.filters | index("http")))
  | positive_traffic
  | select(test("trycloudflare\\.com"))] | length')

6. Monitoring & Detection

6.1 Configure Logging

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 8.2
NIST 800-53 AU-2, AU-6

Description

Configure comprehensive logging for Zero Trust activities and integrate with SIEM for security monitoring.

Rationale

Why This Matters:

  • Without comprehensive Access, Gateway, and audit logs, malicious activity and policy violations go undetected and uninvestigable
  • Exporting via Logpush to a SIEM enables correlation, alerting, and long-term retention beyond the dashboard’s limited window
  • Logs of admin changes, posture failures, and denied access provide the evidence needed for incident response and forensics
  • Audit trails support compliance obligations and demonstrate that Zero Trust controls are operating as designed

Attack Prevented: Undetected intrusion, delayed breach discovery, repudiation, unnoticed configuration tampering

ClickOps Implementation

Step 1: Review Default Logs

  1. Navigate to: Zero Trust → Insights & Logs → Logs
  2. Review available log types:
    • Access authentication logs
    • DNS query logs
    • HTTP request logs
    • Network logs

Step 2: Configure Log Export (Enterprise/Contract plans only)

  1. In Insights & Logs → Logs, select Manage Logpush
  2. Select Create a Logpush job
  3. Select destination:
    • Splunk
    • Azure Blob Storage
    • Amazon S3
    • Google Cloud Storage
  4. Configure log fields and filters

Step 3: Enable Real-Time Logs

  1. Navigate to: Zero Trust → Insights & Logs → Logs and open DNS query logs, Network logs, or HTTP request logs
  2. Review real-time activity
  3. Configure dashboards for monitoring

Code Pack: Terraform
hth-cloudflare-6.01-configure-logging.tf View source on GitHub ↗
resource "cloudflare_logpush_job" "access_requests" {
  account_id          = var.cloudflare_account_id
  name                = "hth-access-requests"
  dataset             = "access_requests"
  destination_conf    = var.logpush_destination
  ownership_challenge = var.logpush_ownership_challenge
  enabled             = true
}

resource "cloudflare_logpush_job" "gateway_dns" {
  account_id          = var.cloudflare_account_id
  name                = "hth-gateway-dns"
  dataset             = "gateway_dns"
  destination_conf    = var.logpush_destination
  ownership_challenge = var.logpush_ownership_challenge
  enabled             = true
}

resource "cloudflare_logpush_job" "gateway_http" {
  account_id          = var.cloudflare_account_id
  name                = "hth-gateway-http"
  dataset             = "gateway_http"
  destination_conf    = var.logpush_destination
  ownership_challenge = var.logpush_ownership_challenge
  enabled             = true
}

resource "cloudflare_logpush_job" "gateway_network" {
  account_id          = var.cloudflare_account_id
  name                = "hth-gateway-network"
  dataset             = "gateway_network"
  destination_conf    = var.logpush_destination
  ownership_challenge = var.logpush_ownership_challenge
  enabled             = true
}

resource "cloudflare_logpush_job" "audit_logs" {
  account_id          = var.cloudflare_account_id
  name                = "hth-audit-logs"
  dataset             = "audit_logs"
  destination_conf    = var.logpush_destination
  ownership_challenge = var.logpush_ownership_challenge
  enabled             = true
}
Code Pack: API Script
hth-cloudflare-6.01-configure-logging.sh View source on GitHub ↗
# List all Logpush jobs and check for Zero Trust datasets
LOGPUSH=$(cf_get "/accounts/${CF_ACCOUNT_ID}/logpush/jobs") || {
  fail "6.1 Unable to retrieve Logpush jobs"
  increment_failed
  summary
  exit 0
}

JOB_COUNT=$(echo "${LOGPUSH}" | jq '.result | length')
info "6.1 Found ${JOB_COUNT} Logpush job(s)"

# Check for recommended Zero Trust datasets
RECOMMENDED_DATASETS=("access_requests" "gateway_dns" "gateway_http" "gateway_network" "audit_logs")
MISSING_DATASETS=()

for dataset in "${RECOMMENDED_DATASETS[@]}"; do
  HAS_DATASET=$(echo "${LOGPUSH}" | jq --arg ds "${dataset}" '[.result[] | select(.dataset == $ds and .enabled == true)] | length')
  if [ "${HAS_DATASET}" -gt 0 ]; then
    info "6.1 Logpush configured for '${dataset}'"
  else
    warn "6.1 No active Logpush job for '${dataset}'"
    MISSING_DATASETS+=("${dataset}")
  fi
done

echo "${LOGPUSH}" | jq -r '.result[] | "  - \(.name // "unnamed"): \(.dataset) → \((.destination_conf // "") | split("://")[0]) [\(if .enabled then "enabled" else "disabled" end)]"'
Code Pack: Sigma Detection Rule
hth-cloudflare-6.01-configure-logging.yml View source on GitHub ↗
detection:
    selection:
        ActionType|contains:
            - 'DeleteLogpushJob'
            - 'UpdateLogpushJob'
    condition: selection
fields:
    - ActorEmail
    - ActionType
    - ResourceID
    - When

6.2 Key Events to Monitor

Event Log Source Detection Use Case
Access denied Access Logs Unauthorized access attempts
Policy block Gateway DNS/HTTP Malware/policy violations
Device posture fail Access Logs Compromised devices
Admin changes Audit Logs Unauthorized modifications
Tunnel disconnection Tunnel Logs Service availability
Isolation triggered Gateway HTTP High-risk browsing
API token created or rolled Audit Logs Unauthorized credential creation
trycloudflare.com resolution Gateway DNS/HTTP Quick tunnel abuse, RAT delivery
DLP profile match Gateway HTTP Sensitive data exfiltration
User risk score elevated Risk Score / Access Logs Account compromise, insider activity

7. Compliance Quick Reference

SOC 2 Trust Services Criteria Mapping

Control ID Cloudflare Control Guide Section
CC6.1 IdP authentication 1.1
CC6.1 MFA enforcement 1.2
CC6.1 Scoped API tokens 1.5
CC6.1 Account 2FA enforcement 1.6
CC6.2 Admin roles 1.4
CC6.6 Access policies 2.1
CC6.6 SSH infrastructure access 2.4
CC6.7 Gateway DLP 3.5
CC7.1 Gateway filtering 3.1
CC7.2 Logging 6.1
CC7.2 User risk score gating 2.5
CC7.2 Quick tunnel abuse detection 5.3

NIST 800-53 Rev 5 Mapping

Control Cloudflare Control Guide Section
IA-2 IdP integration 1.1
IA-2(1) MFA 1.2
IA-2(1) Account member 2FA 1.6
IA-5 API token lifetime and rotation 1.5
AC-3 Access policies 2.1
AC-2(11) Device posture 2.3
AC-2(12) User risk score gating 2.5
AC-4 Gateway DLP 3.5
AC-17 SSH infrastructure access 2.4
SC-7 Gateway policies 3.1
CM-7(2) Quick tunnel blocking 5.3
AU-2 Logging 6.1

Appendix A: Plan Compatibility

Source: Cloudflare Zero Trust plans, checked 2026-09-24.

Feature Free Pay-as-you-go Contract
Access / ZTNA ✅ (teams under 50 users) ✅ ✅
Gateway DNS + HTTP filtering (SWG) ✅ ✅ ✅
Device client + posture checks ✅ ✅ ✅
Browser Isolation ❌ Add-on Add-on
CASB 2 read-only integrations 2 read-only integrations Unlimited (add-on)
DLP Limited predefined profiles Limited predefined profiles Full-featured (add-on)
Logpush ❌ ❌ ✅
Log retention 24 hours 30 days 6 months
Support Community forums Chat and ticket Phone, chat and ticket

Appendix B: References

Official Cloudflare Documentation:

API Documentation:

Compliance Frameworks:

  • SOC 2 Type II (Security, Confidentiality, Availability), ISO 27001:2022, ISO 27018, ISO 27701, PCI DSS Level 1 (Merchant and Service Provider), FedRAMP (In Process, Moderate Baseline) — via Cloudflare Trust Hub

Security Incidents:

  • November 2023 — Nation-state actor accessed internal Atlassian systems. Using credentials stolen during the October 2023 Okta breach that Cloudflare failed to rotate, attackers accessed Cloudflare’s self-hosted Atlassian Confluence, Jira, and Bitbucket between November 14-24, 2023. No customer data or systems were impacted. Cloudflare rotated over 5,000 production credentials, reimaged all machines across its global network, and physically segmented test/staging systems. (Cloudflare Blog)
  • August 2025 — Salesloft Drift supply chain compromise exposed Cloudflare’s Salesforce data. The threat actor tracked as GRUB1 abused the Salesloft Drift integration with Salesforce to access Cloudflare’s Salesforce tenant between August 12-17, 2025, exfiltrating support case data — including the text customers had typed into those cases. Cloudflare found 104 API tokens in the exposed data and rotated all of them as a precaution; no core infrastructure or services were compromised. Cloudflare disclosed the incident on September 2, 2025, disconnected Salesloft, rotated every credential shared through support cases, and moved to enforce least privilege and IP restrictions on third-party application connections. (Cloudflare Blog)

Changelog

Date Version Maturity Changes Author
2026-09-25 0.2.3 ai-drafted · ai-validated Added ai-validated to this guide’s status set, which now reads ai-drafted + ai-validated: an AI agent exercised this guidance against a live Cloudflare account and the guidance survived that contact; no human practitioner has reviewed or applied it, so the guide claims no ni- status. What was exercised (2026-09-25): a validate-hth-guide run (Phases 4-6) walked every control’s console path on a live Cloudflare Zero Trust Free account in a signed-in browser. 19 of 23 ClickOps surfaces came back live and carry a mark on their ClickOps heading: 1.1-1.6, 2.1-2.3, 3.1-3.3, 3.5, 4.1-4.3 and 5.1-5.3. Four carry no mark. Three are not offered on this plan: 2.5 (user risk score, Enterprise), 3.4 (Browser Isolation add-on) and 6.1 Step 2 (Logpush). The fourth is 2.4: its Step 4 CA public key appears only after an account-wide SSH CA has been generated, and this run did not generate one. No Code Pack was executed (the read-only API token was minted but not yet stored), so no Code surface is marked. Corrections from the live walk: the Zero Trust sidebar groups are Traffic controls and Insights & Logs (not Traffic policies and Insights); device profiles open with Edit profile settings; the application types are Self-hosted and private, SaaS applications and Infrastructure, and policies are added with Create new policy or Add current policies; device enrollment sits under Devices → Management with an Authentication → Identity section; the identity provider picker reads Add an identity provider, Microsoft Entra ID, OpenID Connect, App ID and Auth URL; 2FA enforcement is at Manage Account → Members → Settings; the DNS category is DGA Domains; DLP match count and confidence threshold are set per profile; the Infrastructure application form uses Target criteria and Connection context; and the SSH CA is created with Add a certificate → Generate SSH CA Claude Code (Opus 5.5)
2026-09-25 0.2.2 ai-drafted validate-hth-guide run (Phases 4-6): 0 surfaces exercised live (dashboard signed out, no API credential), so maturity is unchanged; every console path re-checked against current Cloudflare docs and corrected (Zero Trust nav, Cloudflare One Client naming, role names, MFA selector, category selectors, Split Tunnels Include-mode guidance, no Screen lock posture check, and a 2.5 Step 1 path that uses the same Team & Resources label as 1.3 and 4.1–4.3 and ends at the Risk behaviors tab); Appendix A rebuilt from the Free / Pay-as-you-go / Contract plans page; Code Packs for 1.5, 1.6, 2.4, 2.5, 3.5, 5.3 and a cloudflared CLI pack for 5.1; fail-open audits and invalid Terraform fixed, writes gated behind HTH_APPLY=1; after an independent audit, the MFA audit checks every Allow and Bypass policy with Policy > Application > Organization precedence, every paginated list is read in full, the posture and Logpush audits fail on gaps, negated Gateway expressions no longer count, and API packs exit 1 on any failure; after a second independent audit, the identity provider audit counts only corporate IdP types (not One-time PIN, social providers, or the Cloudflare IdP), the WARP lock check reads allowed_to_leave: false correctly, the WARP settings audit accepts the guide’s 1–15 minute Timeout and never writes a looser value, an API response that is not valid JSON fails the audit instead of reading as an empty list, the tunnel Access audit follows Access’s wildcard and most-specific-match rules and requires an application that enforces a policy, the risk score audit requires an Access policy acting on High risk, the isolation audit checks the Step 2 settings, and the cloudflared pack reports an empty account as skipped and idle tunnels as a failure Claude Code (Opus 5.5)
2026-08-08 0.2.1 ai-drafted Cheat-sheet cell repair: added missing Attack Prevented line(s) to §1.1, §1.3, §2.1, §3.1, §4.3, §5.1 (no content-facts changed) Claude Code (Fable 5)
2026-08-03 0.2.0 ai-drafted Add API token/Global API Key retirement (1.5), account 2FA enforcement (1.6), Access for Infrastructure SSH (2.4), user risk score gating (2.5), Gateway DLP (3.5), TryCloudflare quick tunnel abuse detection (5.3); correct the Salesloft Drift incident entry to the August 2025 Salesforce compromise with Cloudflare’s own disclosure Claude Code (Sonnet 5)
2026-06-29 0.1.1 ai-drafted Add cheat-sheet Description and Rationale for all controls Claude Code (Opus 4.8)
2025-02-05 0.1.0 ai-drafted Initial guide with Access, Gateway, and WARP hardening Claude Code (Opus 4.5)

Contributing

Found an issue or want to improve this guide?