<- blog

Every Deployment URL Is Part of the Auth Contract

New Cloudflare Access and GitHub OAuth controls make preview URLs, callbacks, token rotation, and retirement one deployment-level contract.

#security#deployment#developer-tools

Cloudflare and GitHub shipped two authentication changes on 14 August that solve opposite sides of the same deployment problem.

Cloudflare can now attach Access directly to one Worker or every Worker in an account. The policy can cover preview deployments, production traffic, or both, regardless of whether a request arrives through a custom domain, route, workers.dev hostname, or preview URL. Protection follows the deployed application instead of relying on an operator to remember every hostname.

GitHub now lets OAuth apps register up to ten callback URLs, opt into expiring access tokens, and control wildcard callback matching. That makes it easier for one integration to support several environments. It also makes the list of places allowed to receive an authorization code a larger, more explicit security surface.

The useful synthesis is not simply “turn authentication on.” Modern deployment systems create more reachable URLs than many teams inventory, while OAuth clients can now trust more return destinations than one production callback. The environment map connecting those two controls is the auth contract.

The repeated angle to avoid

The ten most recent posts here covered checkout-field experiments, agent-plugin trust, context copies, form-security states, server-side agent conversion tracking, AI work receipts, prototype exits, billing handovers, code-quality policy, and comment-triggered execution. Older overlapping posts covered credential kill switches, public preview exposure, deploy contracts, runtime grants, and repository operating boundaries.

The weak version of this article would repeat the old X needs Y formula: preview deployments need authentication. The sharper thesis is that exposure control, OAuth callback delivery, token lifecycle, and application authorization are separate boundaries joined by deployment topology. Making one boundary easier does not configure the others, and a hostname list is no longer a sufficient inventory.

Fresh evidence and background context

The source map separates this week's product evidence from durable protocol guidance:

Source Freshness What it contributes
Cloudflare Access on Workers 14 August 2026 Worker-level and account-level protection across preview and production URLs, identity in Worker context, service-token access, and policy precedence
GitHub OAuth app updates 14 August 2026 Up to ten callback URLs, configurable wildcard matching, eight-hour access tokens, six-month refresh tokens, and rollout options
Cloudflare Access configuration for Workers Current primary documentation Exact protection scopes, public bypass behaviour, policy ordering, identity simulation, and API destination types
GitHub OAuth authorization documentation Current primary documentation state, PKCE, redirect matching, token response fields, refresh semantics, and migration behaviour
OAuth 2.0 Security Best Current Practice Background, January 2025 Exact redirect matching, open-redirect protection, authorization-code safeguards, and refresh-token protection

The fresh announcements do not describe one shared product. Their overlap exposes an implementation gap: platforms can protect an application's growing URL surface and support its growing callback list, but the operator still has to decide which environment can authenticate which principal against which data and where the resulting grant may live.

Authentication now has at least four boundaries

Teams often compress an entire login flow into one status: “protected.” That hides four different decisions.

Boundary Control A positive result proves It does not prove
Exposure Access, edge policy, private network, or another front gate This request passed the rule for reaching the application The user may perform every application action
Callback delivery Registered OAuth callback plus exact redirect_uri matching The authorization response went to an allowed endpoint The state belongs to this browser or environment
Grant lifecycle Code exchange, token storage, expiry, refresh, and revocation The app currently holds a grant issued for a user and scope The current business operation is allowed
Application authorization Roles, resource ownership, tenant, environment, and action policy This principal may perform this operation on this resource The token or session cannot be stolen or misused elsewhere

Cloudflare's new Worker control strengthens the first boundary. GitHub's callback and token updates affect the second and third. The application still owns the fourth.

This matters for an internal quote tool, ecommerce operations dashboard, analytics explorer, or content utility. Requiring company login can stop anonymous access while still letting every employee export customer records. A valid GitHub OAuth grant can identify a user while still carrying scopes that are too broad for a preview. An exact callback can receive the right authorization code while an invalid state value reveals that the browser did not initiate that transaction.

Use different evidence for each boundary rather than one authenticated: true flag.

Application-level protection closes alternate URL gaps

Cloudflare's implementation change is useful because a Worker can be reachable through several address types:

worker deployment
  -> custom domain
  -> route on an existing domain
  -> workers.dev hostname
  -> version or preview URL

Under hostname-only protection, adding a new route or domain could create an unprotected way to reach the same code. Worker-level Access instead covers the Worker across associated domains and previews. Account-level Access can make every current and future Worker private by default, with separate choices for previews only or all traffic.

That changes the safer default for teams using AI builders, internal platforms, or frequent previews. The deployment object is a more durable policy target than today's hostname list.

There are still two sharp edges.

First, Cloudflare documents a precedence order: hostname or path policy is most specific, then Worker policy, then account-level policy. A narrow rule can therefore override the broad default. Removing a Worker rule can reveal the account rule underneath; adding a public bypass can intentionally exempt one Worker. An account-wide “private” setting is not proof that no exception exists.

Second, Access authenticates before application code runs, but business authorization remains inside the application. ctx.access.getIdentity() can provide email, groups, and other identity fields without the Worker parsing a JWT itself. The handler must still decide whether that identity can read an invoice, edit a product, approve a refund, export leads, or change another user's record.

A practical request path is:

request
  -> deployment-level Access policy
  -> authenticated identity or service token
  -> application role and tenant check
  -> resource and action authorization
  -> side effect
  -> audit event

Do not skip the middle checks because the edge supplied a trustworthy identity.

Callback lists are executable deployment inventory

GitHub OAuth apps can now register up to ten callback URLs. If several explicit URLs are configured, the authorization request should provide redirect_uri; otherwise, the first callback is used. This can support stable production, staging, regional, or migration endpoints without creating a separate app for every hostname.

The security consequence is easy to state: each registered callback is a place GitHub may send a temporary authorization code and the user-agent carrying it.

GitHub also exposes wildcard matching per callback. With a callback such as https://example.com/auth/callback, a wildcard can match subdomains and deeper paths under that base while keeping the registrable host and port constrained. GitHub warns that this is dangerous when the matching site hosts user-controlled content or does not tightly control routes.

The protocol background is stricter. RFC 9700 recommends exact string matching for pre-registered redirect URIs, apart from the native-app localhost port exception, and rejects open redirectors because they can leak codes or tokens. GitHub's explicit callback list makes that safer posture easier to use.

There is also a migration task hiding in the release. GitHub says apps that previously had only one callback URL have wildcard behaviour enabled to preserve legacy matching, now made visible and configurable. Existing app owners should not infer that an untouched registration is already exact-match only.

Use this decision table:

Environment shape Callback posture Identity and data posture
Stable production origin Explicit production callback; wildcard off Production client or app registration, production secrets, normal customer data rules
Stable staging origin Explicit staging callback Prefer non-production client and test identities; no production data by default
Ephemeral branch preview Do not register every generated preview as a callback Protect the preview; disable OAuth or use a stable controlled callback service
Tenant subdomains Prefer one stable callback outside tenant-controlled routes Resolve tenant after validation; use a wildcard only when every matching host and path is controlled
Temporary migration domain Explicit callback with an owner and deletion date Remove after cutover and test that the retired callback no longer works

The important distinction is that a URL can be safe to view after company login without being safe to receive an OAuth authorization response.

Do not solve preview sprawl with a broad wildcard

Per-branch URLs are deliberately ephemeral. OAuth callback registrations are durable trust configuration. Making the second follow the first automatically creates a lifecycle mismatch.

A branch name, tenant slug, user-created project, or generated route can become part of a preview hostname or path. If a wildcard callback includes that namespace, whoever can claim a matching location may gain a place to receive a code. Access protection reduces who can reach the preview, but it does not make every route on every matching origin an appropriate OAuth callback.

Use one of three patterns instead:

  1. No OAuth on disposable previews. Exercise UI and unauthenticated behaviour with fixtures. Test the real authorization flow only on stable staging.
  2. A separate non-production OAuth client. Give staging and controlled previews narrower scopes, test organizations, synthetic records, and separate token storage.
  3. A stable callback broker. Receive the provider response on one exact callback, validate the transaction there, exchange the code there, and continue only to a server-side allowlisted preview destination.

The broker pattern must not become an open redirect. It should store a one-time transaction record before authorization begins, including the intended environment and a return target selected from an allowlist. It should send an unguessable state value and PKCE challenge, validate the returned state, exchange the code using the same explicit callback, and never forward the provider's code or token through a preview URL.

If the business cannot operate and monitor that broker safely, stable staging is the better default.

Short-lived tokens create a state machine, not a checkbox

GitHub OAuth apps can now request an access token that lasts eight hours and a refresh token valid for six months without use. Requesting offline_access enables the pattern for an individual authorization, which gives an existing app a gradual rollout path. New OAuth apps default to expiring user access tokens.

This reduces the useful life of a leaked access token, but it adds lifecycle work:

no_grant
  -> authorization_started
  -> code_received
  -> token_pair_active
  -> access_expired
  -> refresh_in_progress
  -> replacement_pair_active
  -> refresh_expired_or_revoked
  -> reauthorization_required

GitHub's documentation says a successful refresh returns a new access token and a new refresh token. Once the refresh token is used, both that refresh token and the old access token stop working. That has several implementation consequences:

  • replace the token pair atomically in storage;
  • serialize refresh for one user grant so two requests do not race with the same refresh token;
  • reload the latest stored pair before retrying a failed API request;
  • treat a crash after remote refresh but before local persistence as a possible reauthorization event;
  • keep refresh tokens out of browser storage, logs, analytics, preview artifacts, and client-visible error reports;
  • test the signed-out or reconnect path instead of assuming refresh always succeeds.

A global switch is not a complete migration. GitHub notes that enabling expiration does not expire already-issued long-lived tokens. Existing users need to authorize again to receive the new token pattern. Apps that also support GitHub Enterprise Server must tolerate an environment where offline_access has no effect and no refresh token is returned.

The rollout therefore needs cohorts and evidence:

  1. Add token-response parsing that accepts expiring and non-expiring forms.
  2. Store expiry and refresh state without exposing either token.
  3. Enable offline_access for test users and exercise concurrent requests, expiry, refresh, revocation, and reconnect.
  4. Expand the cohort while measuring refresh failures and forced sign-ins.
  5. Require expiring tokens only after every deployed client version supports them.
  6. Inventory and deliberately retire old long-lived grants; do not wait for the new setting to change them retroactively.

Short-lived access is safer only when refresh behaviour is reliable and the longer-lived refresh credential receives stronger storage and audit controls.

Keep one deployment authorization manifest

A useful operator asset joins reachability, callback trust, grant storage, and business authority. Keep it with infrastructure configuration or an operations runbook rather than in a dashboard screenshot.

deployment_auth_contract:
  application: internal-commerce-ops
  owner: web_operations
  environments:
    - name: production
      deployment: cloudflare_worker_production
      reachable_urls:
        - https://ops.example.com
        - https://commerce-ops.example.workers.dev
      exposure:
        access_scope: worker_all_traffic
        public_bypass: false
        allowed_identities:
          - ecommerce-operations-group
        service_tokens:
          - reporting-job-read-only
      oauth:
        provider: github
        client_registration: commerce-ops-production
        callback: https://ops.example.com/auth/github/callback
        wildcard: false
        redirect_uri_required: true
        pkce: required
        state: one_time_server_side
        token_mode: expiring_with_refresh
        token_store: encrypted_production_store
      authorization:
        default_role: no_access
        write_roles:
          - ecommerce-admin
        customer_export: separate_approval
      evidence:
        - alternate_url_access_tests
        - callback_negative_tests
        - refresh_concurrency_test
        - role_authorization_tests
    - name: branch_preview
      deployment: cloudflare_worker_preview
      exposure:
        access_scope: account_all_previews
        public_bypass: false
        allowed_identities:
          - engineering-group
      oauth:
        provider: none
        test_mode: fixture_identity
      data:
        production_backends: denied
        fixtures_only: true
      retirement:
        expires_with_deployment: true
  exceptions:
    review_owner: security
    maximum_age_days: 30
    required_fields:
      - reason
      - affected_urls
      - expiry
      - rollback

The manifest records names and policy, not secrets. Its main value is the join. A worker, URL, callback, OAuth client, token store, role policy, test set, and retirement rule become reviewable as one environment instead of seven unrelated settings.

Test the negative paths across every address type

A login test on the preferred custom domain is not enough. Exercise the paths the platform can also serve.

Exposure tests

  • unauthenticated request to each custom domain;
  • unauthenticated request to the workers.dev hostname;
  • unauthenticated request to a current preview URL;
  • request through every configured route and sensitive path;
  • authenticated user outside the allowed group;
  • service token with read permission attempting a write;
  • Worker with a specific hostname rule that differs from the account default;
  • a public bypass exception before and after its expiry date.

Callback tests

  • exact registered callback succeeds;
  • changed scheme, host, port, path, case, or trailing slash fails unless explicitly intended;
  • sibling subdomain and deeper path fail when wildcard matching is off;
  • expired or unknown state fails before code exchange;
  • PKCE verifier mismatch fails;
  • arbitrary next or return URL cannot turn the callback into an open redirect;
  • a deleted preview or retired migration domain cannot still receive a valid response.

Token lifecycle tests

  • access works before its recorded expiry;
  • one refresh produces and persists one replacement pair;
  • the old access and refresh tokens fail after replacement;
  • two simultaneous API requests do not perform competing refreshes;
  • refresh expiry or revocation reaches a clear reconnect path;
  • logs and traces contain token identifiers or outcomes, never token values;
  • existing long-lived grants remain visible until deliberately reauthorized or revoked.

Application authorization tests

  • a valid Access identity with no app role is denied;
  • a user from the right group cannot cross tenants or environments;
  • preview identities cannot reach production data or side effects;
  • an OAuth scope does not substitute for application-level resource ownership;
  • an authenticated request still receives idempotency, rate, and audit controls on mutations.

The test matrix should prove the boundary that rejects each invalid case, not merely that the UI eventually displays an error.

Failure modes worth reviewing

The preferred hostname is private, but an alternate route is public

The team tests ops.example.com and forgets a workers.dev address or newly added route. Application-level or account-level protection reduces this gap, but exceptions and policy precedence still need inspection.

Company login becomes administrator authorization

The Worker receives a valid employee identity and grants broad access because the email domain matches. Map groups to narrow roles, deny by default, and authorize every sensitive resource and action in the application.

A callback wildcard includes user-controlled space

A tenant, branch, uploaded site, or generated path can claim a location under the wildcard. Disable wildcard matching, register explicit stable callbacks, and ensure no callback endpoint forwards to arbitrary URLs.

Preview code receives a production grant

A protected preview uses the production OAuth client and writes its refresh token into preview storage. Separate clients and stores when environment scopes, users, data, or retention differ; otherwise disable OAuth on ephemeral previews.

Refresh succeeds remotely and persistence fails locally

The old pair is no longer usable, but the replacement never reaches durable storage. Serialize refresh, make storage replacement atomic, alert on the failure, and send the user through a controlled reauthorization path rather than looping on the invalid old token.

The migration switch strands an old client

The app globally requires expiring tokens before every deployed version can refresh them. Roll out with offline_access, observe compatibility, then require expiration after unsupported clients are retired.

An exception becomes permanent

A Worker is made public to support a demo, webhook, or temporary migration. Record the affected URLs, reason, owner, expiry, and automated check that confirms the bypass disappears.

The practical conclusion

Cloudflare's new Access controls make a strong default possible: protection can follow the Worker across current and future URLs instead of depending on a manually maintained hostname perimeter. GitHub's OAuth changes make another good default more practical: several stable environments can use explicit callbacks, while access tokens can expire instead of remaining useful indefinitely.

Neither feature removes the deployment-level design work. Preview URLs still need a deliberate identity and data posture. Callback registrations still decide where codes may travel. Refresh tokens still need storage, serialization, revocation, and recovery. Edge authentication still does not decide which customer record or business action an employee or agent may access.

Treat every environment as one auth contract. List how it is reached, who passes the front gate, which exact callback it uses, where its grants live, what data and actions it can reach, how negative paths are tested, and how the environment is retired. That lets teams keep the speed of generated previews and simpler OAuth setup without turning every new deployment URL into an accidental extension of production trust.

Need technical help?

I'm a software engineer who builds web apps, APIs, and AI tooling. If you've got a project or a problem to talk through, book a free 30-minute call.

Book time with me ->