Authentication answers who are you?

It does not necessarily answer are you currently allowed to do this?

That distinction matters when access depends on a business-specific requirement that does not naturally live in the identity provider. Mandatory security training is a good example.

I built a Cloudflare Worker that uses an Access External Evaluation rule to make training completion part of the authorization decision.

Training compliance administration dashboard

The problem

An identity provider can authenticate a user and supply useful attributes such as group membership. But there are controls that change independently of identity:

  • training completion;
  • acceptance of a policy;
  • completion of an onboarding step;
  • possession of a current entitlement maintained by another system;
  • completion of a risk or compliance check.

Trying to force all of those states into identity groups can work, but it can also create slow synchronization, ownership ambiguity and large numbers of purpose-specific groups.

For this project, I wanted Cloudflare Access to ask an external service a very narrow question:

Has this authenticated user completed the required training?

The authorization flow

The resulting flow is:

User

Identity provider authentication

Cloudflare Access

External Evaluation Worker

D1 training-status lookup

completed? ── yes ──→ allow

     no

    deny

Cloudflare Access still owns the final application access decision. The Worker contributes one policy signal.

That separation is important. The Worker is not a replacement identity provider and does not proxy the protected application.

Keep the evaluator deliberately simple

The core authorization logic in the Worker is intentionally small.

Conceptually:

const email = claims.identity.email;
const username = extractUsername(email);
const trainingStatus = await getUserTrainingStatus(env, username);

return trainingStatus === 'completed';

The surrounding application has authentication, key management, administration and synchronization logic, but the policy decision itself should remain easy to understand and audit.

For this use case:

completed     → allow
started       → deny
not started   → deny
user missing  → deny
error         → deny

That last behaviour is deliberate.

Fail closed

An external evaluator is part of an authorization path. Ambiguity should not silently become access.

If the user cannot be found, the database is unavailable, the request is malformed or evaluation throws an exception, the implementation returns a denial rather than assuming success.

That gives the control a predictable security posture:

Known compliant state → allow
Everything else       → deny

Whether that is appropriate for a specific application depends on its availability requirements, but for sensitive applications I prefer to make the failure mode explicit rather than accidental.

Store compliance state separately from identity

The Worker uses D1 to maintain a small record for each user.

The useful fields are straightforward:

username
first_name
primary_email
training_status
created_at
updated_at

The status is constrained to a known set such as:

not started
started
completed

The evaluator only needs one indexed lookup on the username, which keeps the decision path small.

The broader benefit is ownership clarity: the identity system remains the source of identity, while the evaluator database owns the additional compliance attribute used by this control.

Synchronize identities, not authorization decisions

The project can synchronize users from Okta into the local training database.

I treat that synchronization as administration/data maintenance rather than part of the live access request.

That means the critical request path does not need to call Okta every time somebody opens an application.

Instead:

Okta
  ↓ periodic/admin sync
D1 user records
  ↓ live lookup
External evaluation

This reduces dependencies in the authorization path and gives the Worker a stable local data model to evaluate.

Separate evaluation from administration

The public evaluation endpoint and the administration interface have very different trust requirements.

The evaluation endpoint exists because Cloudflare Access needs to call it as part of a policy decision.

Administrative functions such as:

  • initializing the database;
  • synchronizing users;
  • changing training status;
  • viewing the management dashboard;

are themselves protected with Cloudflare Access.

That creates an important control boundary:

Access policy calls evaluator
Administrators authenticate through Access to manage evaluator state

I do not want an administrative API to become reachable simply because the evaluation endpoint needs to exist.

Signed communication matters

The implementation verifies the Access request before using identity claims and signs the evaluator response with its own key material.

One useful design decision was to split the Worker’s signing material:

  • public key and key identifier can live in KV;
  • private signing key is kept as a Worker secret.

That reflects the different sensitivity of the two values.

Public verification material needs to be retrievable. The private key does not.

Secure initialization is part of the design

Key and database initialization endpoints are operationally convenient, but they are also privileged actions.

They therefore should not be unauthenticated bootstrap shortcuts left exposed indefinitely.

The project protects initialization and administration behind Access and makes private-key handling explicit. When bootstrapping a similar system, I would also document who is permitted to initialize it and how the resulting secret is transferred into the platform’s secret store.

Content Security Policy still matters on an admin dashboard

The administration UI is protected by Access, but authentication does not remove browser security concerns.

The Worker applies a restrictive Content Security Policy and other security headers to the management pages.

This is a useful reminder that Zero Trust access control and application security are complementary controls.

A page can be correctly protected by Access and still be vulnerable to unsafe rendering or cross-site scripting if the application itself is careless.

Audit the decision, not the secret

Authorization decisions should produce useful operational logs, but logs must not become a second copy of sensitive data.

For an evaluator, useful events include:

user identifier
training status
allow / deny result
request correlation data
error category

Things that should not appear in logs include private keys, bearer tokens or raw signed tokens.

The code also sanitizes values before writing them to logs so user-controlled content cannot easily corrupt the log format.

Where this pattern is useful beyond training

The same architecture can evaluate many forms of state that do not belong directly in the identity provider.

Examples include:

  • mandatory policy acknowledgement;
  • completion of privileged-access training;
  • external risk score below a threshold;
  • membership in a current contractor register;
  • completion of device registration in another control system;
  • application-specific approval stored in a business database.

The important question is whether that state is reliable enough to participate in a live authorization decision.

Keep external evaluation narrow

I would resist turning one evaluator into a large policy engine that reimplements all of Access.

A good external evaluator answers a bounded question with a bounded data model.

For this project the question is simply:

Has this user completed the required training?

That makes the control understandable to security teams, application owners and auditors.

The complete implementation is documented in Cloudflare Training Compliance Gateway.

The wider lab that consumes this evaluator is described in Building a Multi-Cloud Zero Trust Lab with Cloudflare and Terraform.