Shared Auth, Local Decisions
AI-written based on my architecture decisions and implementation audit. Technical references checked in September 2026.
I wanted to upload a video to YouTube.
That became a new subdomain. Which became a conversation about authentication. Which became a conversation about what I meant by “shared.”
Same account? Same WorkOS application? Same browser session? Same implementation?
Yes. But those are different things.
I already had an Astro site at chan.dev, an agent-driven captioning service at captioner.chan.dev, and plans for social.chan.dev. I wanted to add services without adding another copy of login, callbacks, cookies, and token refresh every time.
Here’s where we landed:
Auth owns identity mechanics. Each service owns its decisions.
One WorkOS application—to start
Our WorkOS hierarchy is:
chantastic Team└── chan.dev Project ├── Staging Local development └── Production Public services ├── Web Browser and Captioner application └── Devices Device applicationWorkOS Projects group environments. Environments separate identity data and configuration. Applications let clients within an environment share users and organizations while maintaining their own client IDs and application configuration.
These aren’t the same boundaries as a repository, framework, or hostname.
I initially thought about Applications as “web,” “agents,” and maybe “ESP32.” We started with one Production application, named Web, for the browser services and Captioner. The name doesn’t prohibit an agent-facing service.
Since then, Devices has given us a reason to split: it uses a separate application for device bearer sessions, while sharing the same Production identity environment. Auth validates those sessions through a private DevicesIdentity entrypoint.
We can add another Application when a client needs its own authentication configuration or session policy. Doing that later preserves the environment’s identity records, but still requires a deliberate transition for client-specific configuration and credentials.
Our deployment rule is simple: anything on a public domain uses Production. Local development uses explicitly configured Staging credentials. That’s our policy, not a WorkOS requirement that every publicly reachable preview must be Production.
A domain is an address. A Worker is a component.
auth.chan.dev is the public front door to our identity service. It isn’t the name of every underlying capability.
The implementation has two Auth-owned Workers:
chan-identity-web: the SvelteKit site, browser authentication, and private browser- and device-identity entrypoints.chan-identity: private credential validation and workspace provisioning. No public domain orworkers.devendpoint.
Chan stays Astro. Auth and Social use SvelteKit. Captioner keeps its agent-facing API.
Auth, Social, and Devices now share a private monorepo. They still deploy as independent Workers. Sharing a repository hasn’t merged their runtime permissions.
flowchart TD
Chan["chan.dev · Astro"] -->|"BrowserIdentity binding"| Auth["auth.chan.dev · SvelteKit"]
Social["social.chan.dev · SvelteKit"] -->|"BrowserIdentity binding"| Auth
Captioner["captioner.chan.dev · Agent API"] -->|"CaptionerIdentity binding"| Identity["chan-identity · Private Worker"]
Auth -->|"WorkspaceProvisioning binding"| Identity
Devices["devices.chan.dev · Device API"] -->|"DevicesIdentity binding"| Auth
Auth -->|"Browser authentication · Web"| WorkOS["WorkOS · Production"]
Auth -->|"Device bearer validation · Devices"| WorkOS
Identity -->|"Credentials and memberships"| WorkOS
The arrows between Workers are Cloudflare service bindings, not browser requests to public identity endpoints.
A Custom Domain gives people a public address. A binding gives another Worker an explicit connection to a service. Those can evolve independently.
For example, this is the relevant excerpt from Social’s configuration:
{ "services": [ { "binding": "AUTH", "service": "chan-identity-web", "entrypoint": "BrowserIdentity" } ]}We use a named WorkerEntrypoint with a small fetch(Request) contract. Cloudflare’s HTTP binding interface lets that contract use standard requests and responses.
Social doesn’t need a WorkOS API key or the password that seals browser sessions. It needs this binding.
Same application does not mean same session
Pointing two apps at the same WorkOS application doesn’t make one app’s cookie appear on the other.
We explicitly share a sealed browser-session cookie:
Name: __Secure-chan-web-sessionDomain: chan.devPath: /Secure: trueHttpOnly: trueSameSite: LaxThe Domain attribute makes the cookie eligible for requests to the parent domain and its subdomains. HttpOnly prevents page JavaScript from reading it. Secure restricts its transmission to HTTPS. Those attributes have different jobs. Cookie reference
Only Auth holds the sealing password and runs the browser SDK. Chan and Social forward the selected cookie over their binding; they don’t decrypt it.
That gives us two paths.
When someone needs to sign in:
- The service sends them to Auth with a fixed service destination.
- Auth starts the WorkOS flow and creates the SDK’s PKCE/state data.
- WorkOS returns to
https://auth.chan.dev/auth/callback. - Auth completes authentication, sets the shared cookie, and returns them to the service.
When someone already has a usable shared session:
- Their browser sends the cookie to Chan or Social.
- That Worker asks Auth to validate the session.
- Auth returns a small user profile and any replacement session cookie.
- The service applies its own access policy and returns the response.
No second login ceremony. Still a validation step.
WorkOS’s SvelteKit SDK owns the OAuth and session implementation. Our code composes it at Auth and projects a smaller contract to the other applications.
Share identity, not tokens
The browser binding returns a limited profile: user ID, email, verification status, and names. It can also return the session’s organization ID when one is present.
It does not return access tokens or refresh tokens in JSON.
Chan uses that identity for its dashboard. Social requires a verified email. A future publishing feature can require a specific permission or connected channel.
Those are different decisions, made by different services.
| Auth owns | The service owns |
|---|---|
| Authenticate the browser session | Decide which routes require authentication |
| Refresh and clear session credentials | Decide which authenticated users may enter |
| Validate an agent credential and its identity context | Check the service’s grants and scopes |
| Reconcile a personal workspace | Enforce ownership of files, jobs, and connections |
The consumers still have framework glue. Astro middleware and SvelteKit hooks aren’t interchangeable. They both need to validate the binding response and pass cookies back to the browser.
What they no longer have is their own OAuth exchange, token refresh, or session cryptography.
That distinction lets me keep the framework I want at each edge.
Shared cookies are a shared trust decision
There is a cost to the convenience.
Every matching subdomain can receive the bearer cookie—even a service that doesn’t use the identity binding. A compromised sibling server could replay the encrypted cookie without knowing how to decrypt it.
Centralizing the sealing password reduces secret distribution. It doesn’t make sibling hosts isolated from each other.
I’m choosing this for a collection of first-party services I control. Untrusted customer sites would need a different domain and session boundary.
There is also a browser distinction worth keeping: sibling subdomains can be same-site while still being different origins. SameSite=Lax is not permission to accept every sibling’s form submission. Cookie behavior
Our sign-out buttons submit a same-origin POST. The edge checks the original Origin, then delegates logout privately. Auth clears the shared cookie and directs the browser through WorkOS session logout. We kept the framework’s CSRF protections enabled.
That signs out the shared browser session in that profile. It doesn’t revoke an agent’s API key or disconnect YouTube. Those are separate actions. WorkOS sessions
Agents share identity, not browser cookies
Captioner is the useful test here. Its primary interface is an agent flow.
The agent uses a bearer credential. Captioner delegates credential mechanics to the private CaptionerIdentity entrypoint, then checks its own stored grant, organization context, and requested scope.
A credential being valid in WorkOS is not enough to grant access to Captioner.
We kept the existing user-claimed email/code flow during extraction. Native WorkOS Agent Registration is a future option; it is not enabled in this deployment.
This also answers my earlier “web versus agents” question. A service can offer both interfaces and normalize them into the identity information it needs for authorization. It doesn’t need to pretend an API key is a browser session.
Nor does it need a new WorkOS Application just because a request came from an agent.
Personal workspaces: readable names, stable identity
Captioner exposed another shared need: a place for a person’s organization-scoped credentials and membership.
Our answer is a personal workspace represented by a WorkOS organization. A WorkOS organization is an application identity construct—not the WorkOS Project that contains our environments.
The original generated names were derived from escaped email addresses. Useful for uniqueness. Terrible to look at.
We’ve separated the machine identifier from the display name:
External ID: personal:<WorkOS user ID>Name: Chan's personal workspaceThat’s an illustrative new-record shape. Existing organization IDs, external IDs, and custom names are preserved. Recognized generated names can be replaced with something readable without recreating the organization or breaking its keys.
External IDs provide the stable lookup identity. A display name doesn’t have to carry that responsibility.
WorkOS now supports user-owned API keys. They still require an organization and active membership, so they don’t eliminate the personal workspace. They may improve credential ownership when we deliberately adopt them; our existing Captioner keys haven’t been automatically converted. Create a user API key
Invert the dependency before extracting the package
I wanted the provisioning module to be usable outside this repository later.
So the provisioning core doesn’t import WorkOS, Cloudflare, SvelteKit, or environment variables. It receives a directory interface that can find users, find or create workspaces, and inspect or create memberships.
The composition looks like this:
// workos is the configured server SDK; userId is a trusted identity.let directory = workosDirectory(workos)let provisioner = createProvisioner(directory)
// Preview the required changes. No writes.let result = await provisioner.ensurePersonalWorkspace(userId, true)The same core runs behind the private Worker entrypoint and the administrative repair script. Tests supply an in-memory directory.
The core knows the provisioning policy. The adapter knows how to talk to WorkOS. The Worker knows how to expose a bounded private capability.
This is the part I wanted to extract cleanly—not every framework’s request lifecycle.
It follows the same preference I wrote about in Abstract more, better: keep the individual problems separable so their compositions can change.
Idempotent doesn’t mean “undo the administrator”
Provisioning is reconciliation: inspect what’s there, identify permitted repairs, and converge on the expected state.
It runs on Auth’s authenticated landing page and explicitly allowlisted continuation routes. There, reconciliation is best-effort: a provisioning failure doesn’t block successful browser sign-in. Captioner’s claim flow can invoke the same core. Ordinary browser-session lookups don’t provision a workspace on every request.
This is first-use repair, not a guarantee that every account is provisioned at creation. We haven’t added an account-created event consumer.
The membership details matter. WorkOS’s membership list defaults to active memberships. We explicitly include inactive and pending records when deciding whether a record already exists.
Otherwise, “ensure this exists” can confuse “inactive” with “missing.”
Inactive or ambiguous records require review. Intentional deletion still needs an explicit policy: a deleted membership can look exactly like one that was never created. Repeated repair should not silently reverse offboarding.
If we add lifecycle synchronization, it can call this same core. WorkOS’s Events API guidance provides a basis for ordered, replayable processing. We still have to define which events authorize which repairs.
One more distinction: creating a personal workspace doesn’t automatically select it in an existing browser session. Future organization-scoped features must choose that context deliberately.
Centralize the boring failure cases, too
The happy path is only part of what we extracted.
Our browser adapters bound response size and wait time, forward only approved cookies, reject unexpected redirects, and return an unavailable response if identity cannot be established. Private, identity-dependent responses aren’t cacheable; public content doesn’t become dependent on Auth just because the dashboard uses it.
Refresh behavior deserved its own tests. A temporary provider failure is different from a terminal invalid_grant. WorkOS documents that distinction in Session resilience.
Our runtime tests also caught failure-handling issues in the SvelteKit SDK version we deployed, 0.3.0. A narrow, guarded compatibility correction addresses them until a verified upstream release does. The official SDK still handles authentication and cryptography.
The architecture benefit is that we address that behavior at Auth. We don’t patch three different session implementations.
What this architecture leaves at the edges
Social is still an authenticated stub. Uploading, editing metadata, connecting a channel, and publishing come next.
WorkOS Pipes can manage provider connections and refreshed credentials. Signing into my service still doesn’t authorize it to publish to someone’s YouTube channel.
Social will own that workflow and its grants. We’ll explicitly choose user versus user-and-organization connection scope. Pipes Relay has transport limits, so it isn’t something to assume can carry an entire video upload.
Likewise, shared authentication doesn’t complete resource authorization. Each service still needs explicit ownership rules for its resources, including a migration policy for data created before those rules existed.
Other remaining work includes offboarding, recovery when credential issuance is interrupted, and deciding when sensitive operations need fresh authentication.
Those are boundaries to implement, not capabilities we get from adding a subdomain.
Build at the edges. Extract what repeats.
I want new services to teach me what the shared service needs to become.
Build the feature where it’s needed. Find the repeated identity requirement. Give it a narrow interface. Move that capability into Auth.
Leave the service’s decisions with the service.
Today, that means one Production identity environment, one browser-auth implementation, private identity capabilities, and independently evolving applications.
The public contracts are available in Chan’s auth.md, Auth’s auth.md, Social’s auth.md, and Captioner’s auth.md. The supporting platform documentation is linked throughout and collected below.