# Building a Read-Only Cloudflare Worker AI Security Console

> Source: <https://dev.to/mike_anderson_d01f52129fb/building-a-read-only-cloudflare-worker-ai-security-console-4ica>
> Published: 2026-08-25 10:11:12+00:00

Security teams already have WAF events, bot signals, access logs, and SIEM pipelines. The problem is not always data collection. The problem is turning that data into a fast, readable operational view without giving a model unsafe authority.

This implementation uses a Cloudflare Worker as the control layer and Workers AI as the summarization layer. The Worker is deliberately read-only.

It supports four workflows:

For staging examples, I will use `example.com.dev`

. Do not treat that as a real environment.

The model should not be the administrator.

The Worker owns:

Workers AI owns:

That split matters. If the model is allowed to create arbitrary queries or perform Cloudflare write actions, the tool becomes much harder to govern.

``` php
flowchart TD
    A[Security analyst] --> B[Cloudflare Access]
    B --> C[sentinel-cf Worker]
    C --> D[Scope and input validation]
    D --> E[Cloudflare Analytics GraphQL]
    D --> F[Workers AI via AI Gateway]
    E --> G[Normalized metrics]
    F --> H[Summary or explanation]
    G --> I[HTML report]
    H --> I
    C --> J[Workers KV latest digest]
```

| Workflow | Endpoint | Output |
|---|---|---|
| Main menu | `/` |
HTML |
| Natural language query | `/query` |
HTML table/cards with raw JSON toggle |
| Approved query catalogue | `/allowed-queries` |
HTML list with Copy and Use actions |
| Security digest | `/digest?range=7d` |
HTML report |
| Latest scheduled digest | `/digest/latest` |
HTML report from KV |
| Ray ID investigation | `/ray` |
HTML investigation report |
| Health check | `/healthz` |
JSON |

This implementation should not:

The right production pattern is: AI recommends, humans approve, Terraform or approved change control applies.

You need:

`AI`

.Cloudflare documents Worker deployment through Terraform using `cloudflare_worker`

, `cloudflare_worker_version`

, and `cloudflare_workers_deployment`

. Worker version modules should use `content_file`

where practical to avoid storing large Worker code directly in Terraform state. See [Cloudflare Workers IaC](https://developers.cloudflare.com/workers/platform/infrastructure-as-code/).

Use these Worker bindings and variables:

| Type | Name | Example |
|---|---|---|
| Workers AI binding | `AI` |
Workers AI Catalog |
| KV binding | `DIGEST_KV` |
`sentinel-cf-uat-digests` |
| Secret | `CF_ANALYTICS_TOKEN` |
Redacted |
| Plain variable | `APP_HOSTNAME` |
`sentinel-cf.example.workers.dev` |
| Plain variable | `AI_GATEWAY_ID` |
`sentinel-cf-gateway` |
| Plain variable | `ALLOWED_ZONE_TAGS` |
Zone IDs, comma-separated |
| Plain variable | `ENVIRONMENT` |
`uat` |
| Plain variable | `DEFAULT_DIGEST_RANGE` |
`7d` |
| Plain variable | `DIGEST_TITLE` |
`SENTINEL-CF Security Posture Digest` |

`ALLOWED_ZONE_TAGS`

must contain Cloudflare Zone IDs, not domain names such as `example.com.dev`

.

For UAT, I prefer building the first version in the Cloudflare console. The console path makes it easier to prove each moving part before Terraform becomes the source of truth.

The practical order is:

In Cloudflare, create a dedicated API token for this Worker:

``` php
Manage account -> Account API Tokens -> Create Token -> Create Custom Token
```

Use the minimum read-only permission required by the Analytics GraphQL API:

```
Permission group: Analytics
Permission: Read
Scope: Selected zone only
Zone: example.com.dev
```

Do not grant WAF edit, DNS edit, Access edit, Rules edit, or account administrator permissions.

Store the value as:

```
CF_ANALYTICS_TOKEN
```

You will add it to the Worker as a secret later. Do not paste it into the Worker JavaScript.

In Cloudflare:

``` php
Workers & Pages -> Create Worker -> Start with Hello World
```

Use a simple name:

```
sentinel-cf
```

Deploy the starter Worker first. This proves the Worker route exists before adding the full security console code.

Before exposing the security console, put Cloudflare Access in front of it.

In the Worker creation flow or the Worker Access tab:

```
Protect with Cloudflare Access: On
Scope: All traffic
Policy action: Allow
Policy name: sentinel-cf-bootstrap-admin
```

For a single UAT administrator, use your own verified identity. For a team, use an Access group or identity provider group instead of adding users one by one.

A production policy should usually require:

Open the Worker:

``` php
Workers & Pages -> sentinel-cf -> Bindings -> Add binding -> Workers AI
```

Set:

```
Variable name: AI
```

Workers AI bindings allow Worker code to invoke models through `env.AI.run(...)`

. See [Workers AI bindings](https://developers.cloudflare.com/workers-ai/configuration/bindings/).

Create a KV namespace for the latest scheduled digest:

``` php
Workers & Pages -> KV -> Create namespace
```

Use:

```
sentinel-cf-uat-digests
```

Then bind it to the Worker:

``` php
Workers & Pages -> sentinel-cf -> Bindings -> Add binding -> KV namespace
```

Set:

```
Variable name: DIGEST_KV
KV namespace: sentinel-cf-uat-digests
```

KV is used only for storing the latest generated digest. It is not used for secrets.

Create an AI Gateway for observability and control:

``` php
AI -> AI Gateway -> Create gateway
```

Use:

```
Gateway ID: sentinel-cf-gateway
Collect logs: On, if approved by your security policy
Cache responses: Off for security analytics
Rate limit requests: On for production
Spend limits: On for production
Authenticated Gateway: On where available
```

Security analytics can contain sensitive paths, rule names, source geography, and investigation context. Treat AI Gateway logs as security logs and restrict who can read them.

Open:

``` php
Workers & Pages -> sentinel-cf -> Settings -> Variables and Secrets
```

Add this secret:

```
Type: Secret
Name: CF_ANALYTICS_TOKEN
Value: <the read-only analytics token>
```

Add these plain variables:

```
APP_HOSTNAME=sentinel-cf.example.workers.dev
AI_GATEWAY_ID=sentinel-cf-gateway
ALLOWED_ZONE_TAGS=<cloudflare-zone-id>
ENVIRONMENT=uat
DEFAULT_DIGEST_RANGE=7d
DIGEST_TITLE=SENTINEL-CF Security Posture Digest
```

`ALLOWED_ZONE_TAGS`

must be Cloudflare Zone IDs, not domain names. If you allow more than one zone, use a comma-separated list:

```
ALLOWED_ZONE_TAGS=<zone-id-1>,<zone-id-2>
```

Open:

``` php
Workers & Pages -> sentinel-cf -> Edit code
```

Replace the starter Worker code with the `sentinel-cf-worker.js`

implementation and deploy it.

The deployed Worker should render HTML by default. JSON should be available only where intentionally exposed, such as `/healthz`

or the `Show raw JSON`

section in query results.

Open:

```
https://sentinel-cf.example.workers.dev/healthz
```

Expected:

```
{
  "status": "ok",
  "mode": "read_only",
  "features": ["nlq", "allowed_query_catalog", "digest", "ray_id_investigator"]
}
```

Open:

```
https://sentinel-cf.example.workers.dev/allowed-queries
```

The page should show approved analyst questions with:

`Copy`

.`Use`

.`Use`

opens `/query`

with the selected question and default window pre-filled. The Worker still enforces fixed read-only intents and zone allowlisting.

Open:

```
https://sentinel-cf.example.workers.dev/query
```

Try approved defensive questions such as:

```
Show me top source countries today
Show potential SQL injection events
Show blocked WAF events in the last 24 hours
Show top targeted URLs this week
Show noisy WAF rules in the last 7 days
```

The Security NLQ page should return readable cards and tables, not raw JSON by default. Raw JSON remains available behind `Show raw JSON`

for validation.

Open:

```
https://sentinel-cf.example.workers.dev/digest?range=7d
```

You should see:

Open:

```
https://sentinel-cf.example.workers.dev/ray
```

Enter a Ray ID from Cloudflare Security Events or response headers.

Cloudflare Ray IDs are useful for correlating a request across Security Events, Log Explorer, and server logs, but Cloudflare notes they are not guaranteed unique in all situations. See [Cloudflare Ray ID](https://developers.cloudflare.com/fundamentals/reference/cloudflare-ray-id/).

After manual digest testing works, add a weekly Cron Trigger:

``` php
Workers & Pages -> sentinel-cf -> Settings -> Trigger events -> Cron triggers -> Add
```

Use a weekly UTC schedule:

```
0 1 * * 1
```

Cloudflare Cron Triggers run on UTC time and can take several minutes to propagate. See [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/).

When the cron runs, the Worker should generate the digest and store the latest copy in:

```
DIGEST_KV
```

Open:

```
https://sentinel-cf.example.workers.dev/digest/latest
```

to confirm the stored digest renders.

After the console deployment is working, use Terraform to make the setup repeatable for UAT and production. The Terraform should represent the proven console configuration rather than introducing a separate design.

A clean Terraform handoff should include:

```
main.tf
variables.tf
outputs.tf
terraform.tfvars.example
sentinel-cf-worker.js
cron-trigger.tf.example
README.md
```

Keep the Worker JavaScript in `sentinel-cf-worker.js`

and reference it from Terraform. This keeps the code reviewable and avoids burying a large Worker body inside Terraform.

The important resources are:

```
resource "cloudflare_worker" "sentinel_cf" {
  account_id = var.cloudflare_account_id
  name       = "sentinel-cf"
}

resource "cloudflare_workers_kv_namespace" "sentinel_cf_digests" {
  account_id = var.cloudflare_account_id
  title      = "sentinel-cf-${var.environment}-digests"

  lifecycle {
    prevent_destroy = true
  }
}
```

The Worker version should bind Workers AI, KV, plain variables, and the read-only secret:

```
bindings = [
  {
    type = "ai"
    name = "AI"
  },
  {
    type         = "kv_namespace"
    name         = "DIGEST_KV"
    namespace_id = cloudflare_workers_kv_namespace.sentinel_cf_digests.id
  },
  {
    type = "plain_text"
    name = "APP_HOSTNAME"
    text = var.app_hostname
  },
  {
    type = "plain_text"
    name = "AI_GATEWAY_ID"
    text = var.ai_gateway_id
  },
  {
    type = "plain_text"
    name = "ALLOWED_ZONE_TAGS"
    text = join(",", var.allowed_zone_tags)
  },
  {
    type = "plain_text"
    name = "ENVIRONMENT"
    text = var.environment
  },
  {
    type = "plain_text"
    name = "DEFAULT_DIGEST_RANGE"
    text = var.default_digest_range
  },
  {
    type = "plain_text"
    name = "DIGEST_TITLE"
    text = var.digest_title
  },
  {
    type = "secret_text"
    name = "CF_ANALYTICS_TOKEN"
    text = var.cf_analytics_token
  }
]
```

Expose the important URLs as outputs:

```
output "worker_url" {
  value = "https://${var.app_hostname}"
}

output "allowed_queries_url" {
  value = "https://${var.app_hostname}/allowed-queries"
}

output "digest_url" {
  value = "https://${var.app_hostname}/digest?range=${var.default_digest_range}"
}

output "ray_investigator_url" {
  value = "https://${var.app_hostname}/ray"
}
```

Run:

```
terraform init
terraform fmt -recursive
terraform validate
terraform plan
```

If the Worker or Access app already exists from the dashboard, import existing resources before apply. Do not let Terraform destroy and recreate Access controls without explicit approval.

The recommended production migration is:

`terraform plan`

and review the proposed changes.| Test | Expected result |
|---|---|
`/healthz` |
Worker healthy and read-only. |
`/allowed-queries` |
Approved query list renders; Copy and Use actions work. |
`/query` |
HTML cards/tables render; events show Bangkok-local time and selected window labels. |
`/digest?range=7d` |
HTML digest renders; noisy rules include rule name and rule ID when available. |
`/digest/latest` |
Latest scheduled digest renders from KV after cron has run. |
`/ray` |
Ray investigation form loads. |
| Invalid zone | Request is rejected. |
| Write-style prompt | No change is performed. |
| Unauthenticated request | Blocked by Cloudflare Access. |

Before production:

This is a useful pattern because it gives analysts a faster way to understand Cloudflare security activity without handing automation unsafe authority.

The Worker is the guardrail. AI is the analyst assistant. Terraform is the control plane. That division is what makes the design operationally credible.
