Skip to content
renkudocs
GuidesAuthentication

Authentication

renku/auth supplies authentication mechanisms. Your app owns signup policy, permissions, recovery and UI. The first profile uses discoverable, user-verified ES256 passkeys and authorization code with S256 for app-defined OAuth or OpenID Connect providers. Provider access tokens never reach the browser or persist in the auth store.

On this page

Starting from the getting-started app, replace the config and home page with these files and retain its document layout. AUTH_ORIGIN is trusted configuration: use the exact deployed HTTPS origin, or http://localhost:3000 for local development, never a request Host header.

The reserved /_renku/auth path mounts before page routes. A custom Worker can instead use withAuth(worker, handler).

renku.config.ts
import { bindings, defineConfig } from "renku";

export default defineConfig({
  worker: {
    name: "my-auth-app",
    compatibilityDate: "2026-10-09",
    compatibilityFlags: ["nodejs_compat"],
    env: {
      ASSETS: bindings.assets(),
      AUTH_ORIGIN: bindings.text("https://app.example"),
      AUTH_ROUTING_KEY: bindings.secret(),
    },
    exports: {
      Authentication: { type: "durable-object", storage: "sqlite" },
      AuthFlow: { type: "durable-object", storage: "sqlite" },
      AuthClaim: { type: "durable-object", storage: "sqlite" },
    },
  },
  workerExports: { Authentication: "./src/authority.ts", AuthFlow: "./src/authority.ts", AuthClaim: "./src/authority.ts" },
  auth: "./src/auth.ts",
  routes: [{ layout: "./src/document.tsrx", children: [
    { index: true, page: "./src/home.tsrx" },
  ] }],
});

AuthUserObject stores authentication per user. AuthFlowObject owns a short-lived browser ceremony; AuthClaimObject owns each natural credential or provider identity claim. createAuthentication routes between them without a global authority.

Configure AUTH_ROUTING_KEY as a random secret of at least 32 characters; rotating it invalidates route cookies. Open signup is disabled unless allowSignUp is true. This example enables it. For conditional admission, also configure async authorizeSignUp({ user, context, identity }).

It runs after identity proof validation, before new account enrollment, and fails closed. For external login, identity contains the verified provider, issuer, and immutable subject; it is absent for passkeys and never comes from signupContext.

Browser signup and external-login helpers accept signupContext (a bounded opaque string), persisted in the server-owned browser flow across redirects. Validate it in your application policy and use durable candidate-bound receipts for idempotent admission.

Existing-account sign-in and credential linking do not invoke the hook. Invitation counters, mailbox proofs, onboarding and recovery remain application work. No automatic email-based linking occurs.

src/authority.ts
import { AuthUserObject, AuthFlowObject, AuthClaimObject } from "renku/auth";

export class Authentication extends AuthUserObject {}
export class AuthFlow extends AuthFlowObject {}
export class AuthClaim extends AuthClaimObject {}

The loopback export uses the same Worker in local development and deployments. The binding and administrative methods stay server-only. getSession(authority) returns a plain principal or null; it validates the signed routing envelope before object lookup, then reads current session/account state.

Routing hints are never identity proof. Signup policy can return bounded enrollment context; a synchronous AuthUserObject.enrollment hook stores application intent atomically with the new account.

Use renku/durable-alarm to share scheduling and recovery across foreground requests and application alarms.

src/auth.ts
import { exports } from "cloudflare:workers";
import { createAuthentication } from "renku/auth";
import { serverContext } from "renku/server";
import type { Authentication, AuthFlow, AuthClaim } from "./authority";
type Env = { AUTH_ORIGIN: string; AUTH_ROUTING_KEY: string };

export function authority(env: Env = serverContext<Env>().env) {
  const objects = exports as unknown as {
    Authentication: DurableObjectNamespace<Authentication>;
    AuthFlow: DurableObjectNamespace<AuthFlow>;
    AuthClaim: DurableObjectNamespace<AuthClaim>;
  };
  return createAuthentication<Authentication>({
    origin: env.AUTH_ORIGIN, name: "My app", allowSignUp: true,
    routingKey: env.AUTH_ROUTING_KEY,
    users: objects.Authentication, flows: objects.AuthFlow, claims: objects.AuthClaim,
  });
}
export default {
  fetch(request: Request, env: Env) { return authority(env).fetch(request); },
};

renku/auth/handoff exports createLoginHandoff, LoginHandoffObject and loginHandoffProvider. A trusted login application supplies its HTTPS origin, a per-grant Durable Object namespace, and redirectForClient(clientId), which returns exactly one allowed callback or null.

Validate authorization(request) before showing login UI. After authenticating and authorizing the user, authorize(request, {subject, name}) redirects with a 60-second, single-use code bound to the client, callback and S256 PKCE verifier.

Route /token and /profile to fetch(request, allowIdentity); both code exchange and profile redemption are single-use. Consumers configure loginHandoffProvider(origin, clientId, providerId) in createAuthentication and use its normal OAuth client flow.

Keep identity authorization, admission, UI and deployment policy in the application; use a service binding for Worker-to-Worker provider requests. Do not share session cookies, provider secrets, or routing keys. Handoffs do not assert fresh authentication or revoke sessions in other applications.

SSR request context is available even without server functions. Personalize pages from the current session and prevent shared caching of personalized responses in your app or CDN rules.

Sessions use Secure, HttpOnly, SameSite=Lax __Host cookies with an absolute seven-day default; sessionSeconds can select up to 30 days. Reads do not renew sessions or write storage.

src/home.tsrx
import { getSession } from "renku/auth";
import { authority } from "./auth";
import SignIn from "./sign-in.tsrx";

export default async function Home() {
  const session = await getSession(authority());
  return @{ <main><h1>My app</h1><SignIn initial={session?.name ?? "Signed out"} /></main> };
}

Import browser operations from renku/auth/client. Current native WebAuthn JSON APIs are required; there is no polyfill. Enrollment requests resident credentials, user verification and no attestation. Cross-origin frames are rejected.

Signatures, challenges, RP IDs and origins are verified on the server. Zero or nonmonotonic synced-passkey counters do not block valid signatures. The server rejects stale ceremonies and the helper cancels superseded browser work.

src/sign-in.tsrx
"use client";
import { state, action, onMount, onCleanup } from "renku/client";
import { signUpWithPasskey, signInWithPasskey, signOut,
  getSession, onAuthChange } from "renku/auth/client";

export default function SignIn({ initial }: { initial: string }) {
  const name = state("");
  const message = state(initial);
  const pending = state(false);
  const controller = new AbortController();
  const options = { signal: controller.signal };
  const publish = action((value: string) => message.set(value));
  const busy = action((value: boolean) => pending.set(value));
  const refresh = () => getSession(options)
    .then(session => publish(session?.name ?? "Signed out"))
    .catch(() => publish("Session unavailable"));
  async function run(task: () => Promise<unknown>) {
    busy(true);
    try { await task(); await refresh(); }
    catch { publish("Could not complete sign-in. Please try again."); }
    finally { busy(false); }
  }
  onMount(() => {
    const stop = onAuthChange(() => { void refresh(); });
    onCleanup(stop);
    void refresh();
  });
  onCleanup(() => controller.abort());
  return @{
    <section>
      <label>Display name <input bindValue={name} autocomplete="nickname" /></label>
      <button disabled={pending.get()} onClick={() => run(() => signUpWithPasskey(name.get(), options))}>Create account</button>
      <button disabled={pending.get()} onClick={() => run(() => signInWithPasskey(options))}>Sign in with a passkey</button>
      <button disabled={pending.get()} onClick={() => run(() => signOut(options))}>Sign out</button>
      <output aria-live="polite">{message.get()}</output>
    </section>
  };
}

Use this check inside every protected server function and every retained RpcTarget method, before reading, writing or sending a callback. Authentication middleware alone cannot authorize RPC. App permissions remain separate.

Helpers reset RPC sessions and notify onAuthChange listeners; create a new RPC session after an auth change. WebSocket headers retain the opening cookie, so server authority checks remain essential.

Revocation blocks subsequent checks; it does not roll back application work already authorized or make mutations in another object atomic with auth.

src/account.server.ts
import { getSession } from "renku/auth";
import { RpcError } from "renku/server";
import { authority } from "./auth";

export async function currentAccount() {
  const session = await getSession(authority());
  if (!session) throw new RpcError("AUTH_REQUIRED", "Sign in to continue.");
  // Check application permissions before accessing a resource.
  return { id: session.accountId, name: session.name };
}

OAuth 2 authorizes API access; use its OIDC identity layer for login. Configure exact issuer and approved extra HTTPS endpoint origins. Register https://app.example/_renku/auth/oidc/google/callback with the provider.

Add ID/secret fields to Env and server config bindings (bindings.text / bindings.secret), and forward those selected current Worker values through authority().handle(request, bindings); import signInWithOIDC from renku/auth/client for the browser calls below.

The Google configuration illustrates its published metadata; real provider credentials are still required to validate your registration. Discovery must advertise code, S256 and the selected client authentication method.

Each provider request has one deadline covering headers and the complete response body: two seconds for discovery, five seconds for token, profile and JWKS requests. Responses are limited to 32 KiB and strict UTF-8 object JSON.

A deadline returns AUTH_TIMEOUT (HTTP 408); it does not trigger an automatic retry. Provider helpers leave the app with a full document navigation.

The callback sends a small CSP-protected completion document that notifies other tabs and replaces itself using the Navigation API. v1 supports query callbacks and RS256/ES256 ID tokens; form_post callbacks are outside this profile.

Provider configuration and browser calls
// Add to createAuthentication().providers in src/auth.ts.
// Supply the client ID and secret through server environment bindings.
{
  id: "google",
  issuer: "https://accounts.google.com",
  clientId: env.GOOGLE_CLIENT_ID,
  clientSecret: env.GOOGLE_CLIENT_SECRET,
  clientAuthentication: "client_secret_post",
  endpointOrigins: ["https://oauth2.googleapis.com", "https://www.googleapis.com"],
  algorithms: ["RS256"],
}

// In a browser event handler:
await signInWithOIDC("google", { returnTo: "/" });
// Explicit linking to the current account, after a fresh local sign-in:
await signInWithOIDC("google", { purpose: "link", returnTo: "/settings" });

Use OIDC when a provider supports it. For an OAuth-only provider, the app supplies trusted HTTPS authorization, token and profile endpoints and a profile.identity function that validates the response and returns an immutable subject plus an optional display name.

The example defines GitHub entirely in app configuration; renku has no provider registry. renku owns code/S256, browser-bound state, exact callbacks, token exchange, bounded responses, redirect rejection and sessions.

RFC 9207 issuer checking is required by default; set authorizationResponseIssuer: false only when a provider does not support it. A supplied issuer must always match.

Optional scopes, authorizationParams, profile headers and clientAuthentication (none, client_secret_basic or client_secret_post) describe the provider without overriding security parameters. Register https://app.example/_renku/auth/oauth/github/callback.

Store credentials as encrypted Worker secrets; never include their values in browser code or build output. Construct createAuthentication with current trusted Worker bindings on each request; never populate provider configuration from HTTP input.

This avoids stale Durable Object constructor environments after dashboard edits. Provider error bodies are never returned. OAuth-only sessions have authenticatedAt: 0, cannot authorize credential management, and do not support account linking.

Separate signup methods create separate accounts; there is no email-based merge.

App-defined OAuth provider and browser call
// In your server-owned createAuthentication().providers:
{
  kind: "oauth",
  id: "github",
  issuer: "https://github.com/login/oauth",
  authorizationEndpoint: "https://github.com/login/oauth/authorize",
  tokenEndpoint: "https://github.com/login/oauth/access_token",
  clientId: env.GITHUB_CLIENT_ID,
  clientSecret: env.GITHUB_CLIENT_SECRET,
  clientAuthentication: "client_secret_post",
  authorizationParams: { prompt: "select_account" },
  profile: {
    url: "https://api.github.com/user",
    headers: {
      accept: "application/vnd.github+json",
      "user-agent": "my-app",
      "x-github-api-version": "2026-03-10",
    },
    identity(user) {
      if (!Number.isSafeInteger(user.id) || Number(user.id) <= 0 ||
          typeof user.login !== "string" ||
          !/^[a-zA-Z0-9-]{1,39}$/.test(user.login) || user.type !== "User") return null;
      return { subject: String(user.id), name: user.login };
    },
  },
}

// In a browser event handler:
import { signInWithOAuth } from "renku/auth/client";
await signInWithOAuth("github", { returnTo: "/" });

Enrollment, linking, removal and revoke-all require authentication within five minutes. Every OIDC login requests fresh provider authentication and verifies auth_time; the session preserves that time for sensitive operations. Sign in again if AUTH_REAUTHENTICATE is returned.

Accounts support at most 20 methods and the last method cannot be removed. Removal revokes all sessions. Trusted server code can resolve the user namespace by immutable account ID and call disableAccount(accountId) or revokeAll(accountId); do not expose these as unrestricted server functions.

Browser settings actions
import { addPasskey, listMethods, removeMethod, signOut } from "renku/auth/client";

await addPasskey(); // Requires a sign-in within the last five minutes.
const methods = await listMethods();
// Show methods and ask which one to remove in your app's settings UI.
await removeMethod({ kind: "passkey", id: selectedMethodId });
// Removing a method revokes every session, including this browser's.
await signOut({ all: true }); // Also requires recent authentication.

Subclass AuthUserObject<Env, typeof appSchema> and return the schema from protected applicationSchema(). The constructor validates reserved table names and opens one combined store.

Use protected data for application tables and the transaction callback store for transaction reads/writes. afterAuthentication(session, identity?) runs after authentication commits, including existing-account login.

Its optional AuthenticationIdentity contains the verified external provider, issuer and immutable subject; passkey authentication receives no identity. It is server-only and never sourced from browser signupContext.

Use it to durably establish application onboarding intent; gate product access until onboarding completes. protected applicationAlarm() processes bounded work and returns its next timestamp or null.

Call protected schedule(timestamp) before persisting new work; the framework preserves the earliest app/auth deadline.

Do not open another store or replace alarm() without preserving auth cleanup. requireSession(token), sessionCurrent(principal), updateName(name), and setSessionContext(principal, opaqueContext, expectedRevision) support user-owned application commands.

Context is a preference, never a permission grant. credentials(request) validates only routing; resolve(request) additionally reads the current principal. Always enforce current session and application permissions at the authority that owns a command.

Cloudflare RPC data includes disposal metadata: clone public data and dispose the RPC result before returning it through a renku data server function. Product-specific organizations, email and memberships stay in the app.

Ceremonies expire after five minutes, bind the initiating browser and session, and complete once. One pending ceremony per browser means a new attempt supersedes the previous one across tabs.

  • Cancellation or a lost response can leave an uncertain completion; refresh session state instead of retrying a finish. All auth responses disable caching and the service worker bypasses auth requests.
  • Offline logout cannot report success. startsPerMinute bounds starts per browser flow; apply public abuse controls upstream because a client can discard its flow cookie. Natural claim objects serialize only the same credential or identity. A user is its own session/credential throughput boundary.
  • Expired sessions and flows are reclaimed by bounded alarms. Authentication has no account recovery or schema migration API in v1; deployment data is currently disposable. Physical-device coverage and live provider login require application verification.

You can configure authentication authorities and keep signup, permissions and recovery in application policy.

Continue to buttons, sheets, and themes

← All guides · Explore the docs source (access required) →