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 isolationThe one thing it asks for
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:
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:
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/logoutPasswords 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.
createJwtProviderAdaptercovers standard JWT issuers andcreateCustomProviderAdaptercovers the rest, but a session-and-database provider has no dedicated path.