I've been managing a Cloudflare Access setup for a multi-team environment and noticed our service token list was becoming cluttered. Over time, service tokens are created for various automation tasks, but the associated applications or policies are often decommissioned without the tokens being revoked. These orphaned tokens present a potential security risk and make audit reviews more difficult.
I wrote a Python script to identify and optionally remove these tokens. It works by fetching all service tokens and then checking each one against all Access policies to see if it's still in use. A token is considered orphaned if its `client_id` is not referenced in any policy's `service_token` configuration.
You'll need the `CF_API_TOKEN` with Zone.Zone:Read and Zone.Access:Edit permissions, and your `CF_ACCOUNT_ID`.
```python
import requests
import sys
CF_API_TOKEN = 'your_api_token_here'
CF_ACCOUNT_ID = 'your_account_id_here'
BASE_URL = 'https://api.cloudflare.com/client/v4'
headers = {
'Authorization': f'Bearer {CF_API_TOKEN}',
'Content-Type': 'application/json'
}
def get_service_tokens():
url = f'{BASE_URL}/accounts/{CF_ACCOUNT_ID}/access/service_tokens'
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json()['result']
def get_access_policies(application_id):
url = f'{BASE_URL}/accounts/{CF_ACCOUNT_ID}/access/apps/{application_id}/policies'
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json()['result']
def get_access_applications():
url = f'{BASE_URL}/accounts/{CF_ACCOUNT_ID}/access/apps'
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json()['result']
def main(dry_run=True):
tokens = get_service_tokens()
apps = get_access_applications()
used_token_ids = set()
for app in apps:
policies = get_access_policies(app['id'])
for policy in policies:
for rule in policy.get('include', []):
if 'service_token' in rule:
used_token_ids.add(rule['service_token']['client_id'])
orphaned = [t for t in tokens if t['client_id'] not in used_token_ids]
print(f"Total tokens: {len(tokens)}")
print(f"Orphaned tokens: {len(orphaned)}")
for token in orphaned:
print(f"Orphaned: {token['name']} (ID: {token['client_id']})")
if not dry_run:
url = f'{BASE_URL}/accounts/{CF_ACCOUNT_ID}/access/service_tokens/{token["id"]}'
del_resp = requests.delete(url, headers=headers)
if del_resp.status_code == 200:
print(f" -> Deleted.")
if dry_run:
print("nThis was a dry run. Run with `dry_run=False` to delete.")
if __name__ == '__main__':
main(dry_run=True)
```
Run it initially with the default `dry_run=True` to audit. Review the output, then set `dry_run=False` to perform the cleanup. It's been useful for maintaining a clean Access surface area and simplifying compliance checks. Has anyone else dealt with this? Curious if there are alternative approaches or edge cases I might have missed.
—J
—J
Thanks for sharing this! It's a common issue in Access setups that doesn't get enough attention. A quick caveat: before anyone runs this script with delete permissions, I'd recommend adding a dry-run mode by default and only performing deletions with a `--force` flag. That way you can audit the list first.
Also, have you considered whether tokens might be referenced in other places, like API Shield configurations or as mTLS client certificates? It's probably rare, but worth a quick check in your environment.
Keep it constructive.
A dry-run is mandatory, but the bigger issue is trusting a script that only checks Access policies. As user1079 hinted, the surface area is wider. What about Zero Trust network policies or Gateway configuration? A token could be sitting there, inactive in Access but still a live backdoor.
Before you build a delete flag, you need to map all the potential reference points. Otherwise you're just doing tidy security theater.
Exactly, and it's worse than that. This script's approach assumes you can even get a complete list of all policies and configurations via the API. If your permissions are scoped down, or if you're in a multi-account organization setup, you might only be seeing a slice. You could confidently delete a "ghost" token that's actually being used in a Gateway policy you can't read. The false confidence is the real danger here.
Your k8s cluster is 40% idle.