Cloudflare Zero Trust Seat Cleanup
Building a safe Cloudflare Zero Trust seat cleanup utility with PowerShell and Postman
Important: The repository discussed in this post is an unofficial, personal/community utility. It is not a Cloudflare product and is not provided or supported by Cloudflare. Review the code, run dry runs first, and use removal mode only when you understand the operational impact.
Cloudflare Zero Trust seat management is simple at the conceptual layer: a user consumes a seat when they authenticate into the Zero Trust stack through Access or Gateway/WARP, and a seat is freed when the associated seat flags are cleared. The operational problem is less simple: mature environments accumulate inactive users, contractors, test identities, IdP remnants, and one-off offboarding lists. Cleaning those seats manually is slow, inconsistent, and easy to get wrong.
The Cloudflare Access License Manager repository is a small automation project built around that gap. It provides two equivalent interfaces:
Both interfaces implement the same safety model: enumerate users first, build an explicit candidate set, write or display a report, and only remove seats after an explicit operator action.
The API surface area
The project deliberately uses a narrow API surface:
| Operation | Endpoint | Purpose |
|---|---|---|
| List Zero Trust users | GET /accounts/{account_id}/access/users |
Retrieve users, seat flags, identifiers, and login metadata. |
| Free Zero Trust seats | PATCH /accounts/{account_id}/access/seats |
Update seat records by seat_uid. |
| Verify token | GET /user/tokens/verify |
Confirm the token is valid before an operator proceeds. |
The user-list endpoint returns the fields this utility needs to make deterministic decisions: email, id, uid, seat_uid, access_seat, gateway_seat, last_successful_login, and created_at. The removal endpoint accepts an array of seat update objects. The critical implementation detail is that a seat is released only when both seat flags are cleared:
[
{
"seat_uid": "<seat_uid>",
"access_seat": false,
"gateway_seat": false
}
]
That means the utility does not try to free “only Access” or “only Gateway”. The SeatType setting is a targeting filter — it decides which users qualify for the candidate set — but the removal operation always clears both access_seat and gateway_seat, because that is the API behavior that frees the billable seat.
Two targeting modes
The repo originally focused on inactivity-based cleanup. It now supports two independent targeting modes.
1. Inactivity mode
Inactivity mode selects users whose login reference is older than a configurable threshold:
./scripts/Remove-InactiveAccessSeats.ps1 -InactiveDays 90 -OutputPath report.json
For users with last_successful_login, the script compares that timestamp to now - InactiveDays. If last_successful_login is null, the default behavior is to use created_at as the reference point. This is intentional: a user who was provisioned 300 days ago and never logged in can still consume administrative attention and may still hold a seat. If that is not desired, -ExcludeNeverLoggedIn skips never-logged-in users entirely.
The same model is implemented in Postman under the Inactivity Mode folder. The first request pages through users and builds flaggedSeats; the preview request verifies the API token and displays the set; the remove request is guarded by confirmRemoval = true.
2. Explicit user-list mode
User-list mode exists for workflows that are not based on inactivity: offboarding, contractor cleanup, identity reconciliation, or incident response.
./scripts/Remove-InactiveAccessSeats.ps1 -UserList alice@example.com,bob@example.com -OutputPath offboarding-report.json
The script can also ingest a file:
./scripts/Remove-InactiveAccessSeats.ps1 -UserListPath ./offboard.csv -Remove
Supported file formats are intentionally straightforward:
Internally, the script resolves the supplied tokens against the full user list. Emails are matched case-insensitively; IDs and seat UIDs are matched as identifiers. Entries that cannot be found, or that resolve to users holding no matching seat, are reported and skipped. Resolved seats are de-duplicated by seat_uid so the same user can appear in multiple input files without causing duplicate PATCH entries.
The Postman collection mirrors this with a User-List Mode folder. Operators populate the userList variable with comma-, whitespace-, or newline-separated emails/IDs/seat UIDs. The collection pages through all users, resolves the list, reports unresolved entries to the Postman console, and populates the same flaggedSeats variable used by the shared removal request.
SeatType is a filter, not a partial-removal mechanism
Both targeting modes accept SeatType:
| SeatType | Candidate requirement |
|---|---|
Access |
access_seat = true |
Gateway |
gateway_seat = true |
Either |
either flag is true |
Both |
both flags are true |
This is useful for answering questions such as “only consider Gateway/WARP users from this offboarding list” or “only report users holding both kinds of seats.” But once a user is selected and removal is confirmed, the removal body clears both flags. The implementation treats the API contract as the source of truth and avoids presenting an unsafe illusion of partial seat release.
Safety model
The script is intentionally conservative:
Postman has a similar guardrail: removal refuses to run unless confirmRemoval is set to true, and the guard resets after a successful removal request.
Pagination and rate limiting
The list-users endpoint is paginated. The PowerShell script requests per_page=1000 and loops through result_info.total_pages. The Postman collection uses postman.setNextRequest() so Collection Runner/Newman runs continue until all pages have been scanned.
The PowerShell API wrapper also handles HTTP 429 responses with a small retry loop, honoring Retry-After when present and falling back to a simple increasing wait. That logic lives in one function, Invoke-CfApi, so both list and patch operations get the same error handling.
Why both PowerShell and Postman?
PowerShell and Postman serve different operational audiences.
PowerShell is better for:
Postman is better for:
The important design choice is that the two interfaces do not diverge semantically. They both enumerate the account, build a candidate set, preview it, and submit the same removal body.
Test design
The repo includes a local mock API in test/mock_cf.js. It implements the three endpoints needed by the utility and returns seven fixture users that cover the edge cases:
The PowerShell suite validates both modes end-to-end: all SeatType filters, never-logged-in behavior, inline and file-based user-list parsing, de-duplication, unmatched entries, removal bodies, and -WhatIf non-mutation.
The Postman suite runs mode folders with Newman. It verifies that the collection resolves both inactivity and explicit user-list workflows, sends the right seat_uid values, and always clears both seat flags.
At the time of writing, the validation results are:
Operational guidance
For a production Zero Trust account, I would treat this utility as a reporting system first and a removal system second.
A reasonable rollout pattern is:
For user-list mode, validate the input source. A malformed offboarding CSV should not become a destructive operation. The tool reports skipped entries, but the operator still owns the source-of-truth quality of the list.
Limitations and explicit non-goals
This utility does not:
It is a narrow tool for one job: find or resolve Zero Trust seat holders, preview the candidate set, and free those seats through the public API when explicitly instructed.
Repository
The code is available at: https://github.com/Dgilmore-CF/cloudflare-access-license-manager
Again, this is an unofficial utility. It is not a Cloudflare product, and it is not provided or supported by Cloudflare.
Leave a Comment
Your comment will be reviewed before appearing on the site.
Comments
Be the first to comment on this post!