Manage a least-privilege Cloudflare API token — scoped by policy, optionally IP- and time-restricted — targeting
cloudflare/cloudflare ~> 5.0.
This module manages one cloudflare_api_token:
- 🔑 Policy-scoped — grant the fewest permission groups on the narrowest resources.
- 🌐 IP- and time-bounded — optionally restrict by source CIDR and by a validity window.
- 🔒 Secret out of band — the token's
valueis emitted as a sensitive output only; the module never takes a secret as input, and the value cannot be retrieved again after creation.
💡 Why it matters: an over-scoped, non-expiring token is a standing liability. This module makes you enumerate exactly what the token may do, and surfaces the secret only as a sensitive output for immediate capture into a secrets manager.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
graph LR
user["Cloudflare user (creating credential)"]:::ext
tok["terraform-cloudflare-api-token (this module)"]:::this
res["cloudflare_api_token"]:::keystone
pg["Permission groups + resources (by id)"]:::ext
sm["Secrets manager (value, out of band)"]:::ext
user -->|"API Tokens Write"| tok
pg -->|"permission group ids"| tok
tok -->|"manages"| res
tok -->|"sensitive value"| sm
classDef this fill:#F38020,color:#fff,stroke:#F38020;
classDef keystone fill:#FBAD41,color:#000,stroke:#FBAD41;
classDef ext fill:#eeeeff,color:#333,stroke:#9999ff;
graph TD
t["token = name, policies, condition, expires_on"]:::in
this["cloudflare_api_token.this"]:::this
oid["output: id"]:::out
ost["output: status"]:::out
ov["output: value (sensitive)"]:::out
t --> this
this --> oid
this --> ost
this --> ov
classDef this fill:#F38020,color:#fff,stroke:#F38020;
classDef in fill:#f5f5f5,color:#333,stroke:#cccccc;
classDef out fill:#eeeeff,color:#333,stroke:#9999ff;
Resource inventory
| Resource | Name | Cardinality | Role |
|---|---|---|---|
cloudflare_api_token |
this |
1 (keystone) | A least-privilege API token. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | cloudflare/cloudflare ~> 5.0 |
| Provider block | None — the caller configures the provider and supplies auth out of band. |
| Scope | User-level — this resource takes neither zone_id nor account_id. |
Schema notes that bite (verified against the live provider schema):
- 🔒
valueis write-once. Cloudflare returns the token secret only at creation; the module marks itsensitiveand you must capture it out of band immediately. Losing it means rotating the token. ⚠️ policies[].resourcesis a JSON object string. Build it withjsonencode({...})— e.g.jsonencode({ "com.cloudflare.api.account.zone.<zone_id>" = "*" }).⚠️ policiesandpermission_groupsare lists. Each policy needs aneffect(allow/deny), at least one permission group id, and aresourcesstring.- ℹ️
statusmay be set toactive(default) ordisabled;expiredis a computed state, not something you set. - ℹ️ Permission group ids come from Cloudflare's permission-groups catalog (a data source), not invented.
API Tokens· ReadAPI Tokens· Write
- The credential creating this token needs User API Tokens Write.
- The permission group ids you intend to grant (look them up via the permission-groups data source or the dashboard).
terraform-cloudflare-api-token/
├── providers.tf # terraform{} + required_providers (cloudflare ~> 5.0); no provider block
├── variables.tf # token{} — name, policies (required), condition, validity window
├── main.tf # cloudflare_api_token.this (user-scoped)
├── outputs.tf # id first, then name, status, value (sensitive), issued_on, last_used_on
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical library ignore set
provider "cloudflare" {}
# export CLOUDFLARE_API_TOKEN=... (scoped to API Tokens read/write)
module "dns_ci_token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "ci-dns-editor"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_write_permission_group_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
}
}
output "token_value" {
value = module.dns_ci_token.value # capture into a secrets manager, then remove
sensitive = true
}Consumes
| Input | Type | Typical source |
|---|---|---|
token |
object({...}) |
caller (permission group ids + resource scope) |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Token identifier (not the secret) | audit / rotation |
name |
Token name | audit |
status |
Token status | posture checks |
value |
Sensitive token secret | secrets manager (out of band) |
issued_on / last_used_on |
Timestamps | audit |
1 · Minimal — a single allow policy
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "dns-editor"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_write_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
}
}2 · Account-scoped token
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "account-reader"
policies = [{
effect = "allow"
permission_groups = [{ id = var.account_read_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.${var.account_id}" = "*" })
}]
}
}3 · Multiple permission groups in one policy
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "dns-and-cache"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_write_pg_id }, { id = var.cache_purge_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
}
}4 · IP-restricted automation token
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "ci-runner"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_write_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
condition = { request_ip = { in = ["203.0.113.0/24"] } }
}
}🔒 An IP allowlist means a leaked token is useless from anywhere but your CI egress range.
5 · Expiring token
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "temp-migration"
expires_on = "2026-12-31T23:59:59Z"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_write_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
}
}🔒 Prefer short-lived tokens for one-off work; expiry beats remembering to revoke.
6 · Validity window (not_before + expires_on)
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "scheduled-window"
not_before = "2026-08-01T00:00:00Z"
expires_on = "2026-08-31T23:59:59Z"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_read_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
}
}7 · IP deny list
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "no-tor"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_read_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
condition = { request_ip = { not_in = ["198.51.100.0/24"] } }
}
}8 · Disabled token (created but inactive)
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "break-glass"
status = "disabled"
policies = [{
effect = "allow"
permission_groups = [{ id = var.super_admin_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.${var.account_id}" = "*" })
}]
}
}🔒 A break-glass token can be created
disabledand enabled only during an incident.
9 · Explicit deny policy
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "dns-except-one-zone"
policies = [
{ effect = "allow", permission_groups = [{ id = var.dns_write_pg_id }], resources = jsonencode({ "com.cloudflare.api.account.${var.account_id}" = "*" }) },
{ effect = "deny", permission_groups = [{ id = var.dns_write_pg_id }], resources = jsonencode({ "com.cloudflare.api.account.zone.${var.protected_zone_id}" = "*" }) },
]
}
}10 · Capturing the value into state-free output
module "token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = { name = "app", policies = [{ effect = "allow", permission_groups = [{ id = var.pg_id }], resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" }) }] }
}
output "token_value" {
value = module.token.value
sensitive = true
}🔒 Read the value once (e.g.
terraform output -raw token_value), store it in your secrets manager, and rely on rotation thereafter.
11 · Many tokens from one definition (for_each)
locals {
tokens = {
ci = { pg = var.dns_write_pg_id }
read = { pg = var.dns_read_pg_id }
}
}
module "tokens" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
for_each = local.tokens
token = {
name = "svc-${each.key}"
policies = [{ effect = "allow", permission_groups = [{ id = each.value.pg }], resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" }) }]
}
}12 · Least-privilege DNS automation token (realistic)
module "dns_automation" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "terraform-dns-${var.environment}"
expires_on = timeadd(timestamp(), "8760h") # ~1 year; rotate annually
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_write_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
}]
condition = { request_ip = { in = var.ci_egress_cidrs } }
}
}🔒 Narrow permission group, single zone, IP-bounded, and time-limited — the least-privilege ideal.
13 · 🏗️ End-to-end composition — a token scoped to a zone you manage
provider "cloudflare" {}
variable "cloudflare_account_id" { type = string }
variable "dns_write_pg_id" { type = string }
module "zone" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zone.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
zone = { name = "example.com" }
}
module "zone_dns_token" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
token = {
name = "dns-editor-example-com"
policies = [{
effect = "allow"
permission_groups = [{ id = var.dns_write_pg_id }]
resources = jsonencode({ "com.cloudflare.api.account.zone.${module.zone.id}" = "*" }) # <-- scoped to the zone's id
}]
}
}
output "dns_token" {
value = module.zone_dns_token.value
sensitive = true
}🏗️ The token is scoped precisely to the zone this configuration created — no broader than it needs to be.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
token |
object({...}) |
✅ | — | Token name, policies (required, non-empty), and optional restrictions. |
Full input schema (from variables.tf)
variable "token" {
type = object({
name = string
status = optional(string) # "active" | "disabled"
policies = list(object({
effect = string # "allow" | "deny"
permission_groups = list(object({ id = string }))
resources = string # JSON object string — use jsonencode({...})
}))
condition = optional(object({ request_ip = optional(object({ in = optional(list(string)), not_in = optional(list(string)) })) }))
expires_on = optional(string) # RFC3339
not_before = optional(string) # RFC3339
})
# validation: name non-empty; policies non-empty; each effect ∈ {allow,deny}; each policy has ≥1 permission group; status ∈ {active,disabled}
}| Output | Description | Notes |
|---|---|---|
id |
Token identifier | Not the secret. |
name |
Token name | — |
status |
Token status | — |
value |
Token secret | Sensitive; write-once. |
issued_on / last_used_on |
Timestamps | Provider-computed. |
- User-scoped. Unlike most Cloudflare resources, an API token takes neither
zone_idnoraccount_id; scope lives inside each policy'sresourcesstring. - Secret discipline. The token secret is emitted only as a
sensitiveoutput and is unrecoverable after creation. Capture it once into a secrets manager. The module never accepts a secret as input. resourcesis JSON-as-string. Usejsonencodefor a type-checked map on the caller side that renders to the string the provider expects.- Least privilege by construction. Validations force at least one policy and at least one permission group per policy, so a do-nothing (or accidentally empty) token cannot be created.
| Concern | Secure default | How to opt out (deliberately) |
|---|---|---|
| Secret handling | value is sensitive, never an input |
n/a — secrets stay out of band. |
token.status |
active (usable) |
Set disabled for break-glass tokens. |
| Scope | You must enumerate policies/resources | Broaden resources deliberately (not recommended). |
| Expiry / IP | Off unless set | Set expires_on / condition.request_ip to bound the token. |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin by immutable tag
?ref=v1.0.0, never a branch.
- ✅
terraform validate— parses thetokenobject; enforces non-empty policies and permission groups and the effect/status enums. - ✅
terraform fmt -check— canonical formatting. - ⛔ Not offline: permission group id validity and the resource-string format are only checked at a real
plan/apply.
$ terraform output
id = "ed17574386854bf78a67040be0a770b0"
issued_on = "2026-07-18T15:04:05Z"
last_used_on = tostring(null)
name = "ci-dns-editor"
status = "active"
value = <sensitive>
| Symptom | Cause | Fix |
|---|---|---|
token.policies must contain at least one policy |
Empty policies | Add at least one policy. |
each policy must grant at least one permission group |
Empty permission_groups |
Add a permission group id. |
resources rejected |
Passed a map instead of a string | Wrap with jsonencode({...}). |
| Can't read the token value again | Secret is write-once | Rotate the token; capture the new value immediately. |
| Unknown permission group | Made-up id | Look up ids via the permission-groups data source. |
| Provider auth error | No/insufficient token | Configure the provider with an API Tokens read/write token. |
- Cloudflare provider —
cloudflare_api_token - Sibling module:
terraform-cloudflare-account-member(account IAM) - This module's
SCOPE.md— the cross-module contract.
🧡 "Infrastructure as Code should be standardized, consistent, and secure."