HighX
Platform

Authentication

Where a verified identity comes from, and what HighX does with it.

HighX does not authenticate anyone. It consumes a verified identity and enforces what follows from it. Deciding who someone is stays with you, because that depends on a provider, a session strategy, and an organization model no framework can guess.

your auth  ->  Identity  ->  HighX: routing, authorization, tenant isolation

The one thing it asks for

highx/index.ts
async function identify(request: Request): Promise<Identity | null> {
  return await sessions.authenticate(request);   // yours
}

Everything downstream trusts what this returns. It must verify rather than believe: a header, a cookie, or a body field is a claim until something checks a signature against it.

The identity contract

interface Identity {
  readonly provider: string;   // who vouched for this person
  readonly subject: string;    // their stable ID with that provider
  readonly userId: UserId;
  readonly tenantId: TenantId; // which tenant's data they may reach
  readonly roles: readonly string[];
}

validateIdentity checks this shape at every boundary it crosses, including inside the Durable Object — a value that arrived over RPC is untrusted however it got there.

Email is never the identity key. An address can be reassigned between people, so keying on it would silently transfer account access to whoever received it next. Key on provider plus subject.

Building blocks

Verifying a provider token

const adapter = createJwtProviderAdapter({
  provider: "acme",
  issuer: "https://provider.example.com",
  audience: "highx-app",
  resolveKey: createJwksKeySource({ url: "https://provider.example.com/.well-known/jwks.json" }),
  mapIdentity: directory.mapIdentity,
});

Verification and mapping are separate steps: the adapter checks what the provider asserts, the mapper decides who that subject is in your application. A verified subject with no membership returns null — a different outcome from a failed signature, and both are rejected.

Issuer, audience, signature, expiry, and nbf are checked with configurable clock tolerance. The key is selected by kid and must agree with the token's algorithm, so a token cannot downgrade an RS256 key to symmetric verification; alg: none is rejected outright. Revocation is checked on every request, so a provider logout takes effect immediately even while the token is still cryptographically valid.

Use resolveKey for any provider that rotates on its own schedule, which is all of them. A keys array is for a shared secret you own; with one, a rotation you did not schedule rejects every token until you redeploy. createJwksKeySource caches, treats an unknown kid as a rotation and refetches, rate-limits those refetches so forged kids cannot flood the provider through you, and keeps the keys it holds when a refetch fails. Declare one or the other, never both.

createCustomProviderAdapter produces the identical normalized identity from a bespoke scheme, which is what makes the two interchangeable to everything else.

Sessions

SessionManager issues signed, expiring cookies with rotation and revocation; KvSessionStore persists them. Cookie-authenticated mutations require a CSRF token.

Sessions sit deliberately outside the tenant object: cookie names, login routes, and lifetimes are product decisions.

Configuring it

One hand-written auth.config.ts per project, scaffolded by highx init and never rewritten by the generator:

highx/auth.config.ts
export function authConfig(env: Env) {
  return defineAuth({
    permissions,
    provider: highxAuth({
      sessions: new SessionManager(env.SESSION_SECRET),
      roles: [...appRoles],
    }),
  });
}

Swapping highxAuth for providerAuth({ adapter }) is the whole of adopting Clerk, WorkOS, Auth0, or a custom scheme — everything downstream depends on the AuthProvider contract rather than on sessions.

Live connections

A WebSocket outlives the request that opened it, so a credential verified at connect time is not evidence that it is still valid later. Connections carry an expiry (sessionTtlMs, default five minutes), refuse work past it, and re-verify through your provider's revalidate. A revoked session closes the socket rather than being served until the client happens to disconnect.

Renewal is automatic and does not drop subscriptions. The server sends the expiry in its welcome frame and the client renews at 80% of the remaining lifetime — with a readable token it presents one on the open socket; with an HttpOnly cookie it reconnects and the handshake carries the cookie. Either way subscriptions resume from their cursors.

Tenant isolation

Routing derives the tenant Durable Object from the verified identity, so a tenant ID arriving in a body, an argument, or a query string is data rather than an address. The object then refuses any identity that is not the tenant it serves — SqlTenantBinding reads that tenant from the object's own name.

Two mechanisms rather than one, because routing is what a bug would break and the binding is what makes such a bug fail closed. Both are exercised against two real authenticated users over HTTP, WebSocket, forged arguments, and revoked sessions.

Secrets

A credential your code reads is classified as a secret by inference, and highx doctor warns when one is sitting in committed vars. See highx secret.

Scaffolded password auth

New projects are scaffolded with email/password registration, login, guest continuation, logout, CSRF token retrieval, and secure session cookies. Account records and session records use KV-backed stores in both wrangler dev and production.

The five routes are not written out in the entry — they are provided by the framework and mounted in one line, ready to be renamed, partly replaced, or dropped a route at a time:

highx/index.ts
export default createWorker<Env>({
  auth: authConfig,
  tenant: (env, tenant) => tenantStub(env.TENANT_OBJECT, tenant),
  sessions,
  publicRoutes: passwordAuthRoutes({ sessions, accounts }),
});

The generated endpoints are:

POST /auth/register
POST /auth/login
POST /auth/guest
GET  /auth/csrf
POST /auth/logout

Passwords are hashed with PBKDF2-SHA-256 and are never stored in plaintext. Local HTTP uses non-secure cookies so localhost works; deployed HTTPS uses secure cookies. Unsafe cookie-authenticated requests require the CSRF header: x-highx-csrf. A guest is anonymous but not interchangeable: each guest gets a tenant of its own rather than a shared one every visitor could read and write.

Replace the generated password provider with providerAuth({ adapter }) when adopting an external JWT issuer.

Not built yet

  • No AuthClientAdapter — the browser half of a provider's SDK is yours to wire.
  • No dedicated Better Auth integration. createJwtProviderAdapter covers standard JWT issuers and createCustomProviderAdapter covers the rest, but a session-and-database provider has no dedicated path.

Next

On this page