Identity and authentication
Lakebed creates protected browser sessions for guests without app setup. Google sign-in is built in. Render <SignInWithGoogle /> in dev or a hosted app. You do not need OAuth keys or a separate auth service.
Guest and signed-in users
Use ctx.auth on the server and useAuth() on the client. Check isGuest and isSignedIn separately.
| State | userId | isGuest | isSignedIn |
|---|---|---|---|
| No session | null | false | false |
| Guest session | A user ID | true | false |
| Signed-in account | A user ID | false | true |
A protected guest session does not satisfy a sign-in requirement. Neither flag is true when there is no verified session. On the client, wait for auth.isLoading to become false before showing the current state. isAuthenticated is an older alias for isSignedIn. Use isSignedIn.
"No session" is a normal state on the client. It happens when the app requires sign-in, when session setup failed, and when a guest signed in earlier and the account token is gone from this browser. In the last case the retired guest cookie stays as proof of the transfer, so signing in again restores the account. Do not treat "no session" as "guest".
Gate data components on canAccessApp() from lakebed/client. It is true when session setup finished without an error, a guest or account exists, and the app's sign-in policy is met. Read auth.requireSignIn and auth.error for the copy you show in the gated state. The server still enforces access.
In the gated state show a Retry button and <SignInWithGoogle />. Retry calls retryAuth() to handle temporary failures and a failed sign-out. Google sign-in replaces an expired or revoked token so a pending guest upgrade can finish. A "new guest session" button can call signOut() to cancel a pending upgrade and start a fresh guest. Show it only when the app allows guests and there is no session. Warn that the old guest data will be inaccessible from this browser. It does not delete the data.
The server issues and verifies guest session credentials. The browser keeps them in an HttpOnly cookie. userId is a separate, opaque identifier, not a secret or proof of access. Do not create your own guest IDs, send a user ID as a credential, or infer sign-in from an ID prefix.
Use a current browser for shared guest sessions across tabs. Browsers without Web Locks can create separate guests if tabs start at the same time.
Separate browser profiles have separate sessions. Guest sessions are specific to the app origin.
Session and data lifetime
The browser's guest credential expires 30 days after creation. Clearing browser data removes it. Signing out clears or replaces the cookie. A successful upgrade keeps the cookie for retries, but it no longer grants guest or account access.
Session records have a separate lifetime. Lakebed can remove expired records for guests who never upgraded. It keeps completed upgrade links so userId() references still resolve to the account.
Credential expiry, sign-out, and session-record cleanup do not delete app rows. Sign in before losing the guest credential to keep access to your data through an account.
Deleting a deployment removes its guest session records and app database rows. Termination alone does not delete them. Expired anonymous deployments can be deleted later. Local database contents reset when lakebed dev restarts.
An anonymous deployment and a guest user are different things. Deployment ownership controls who manages the app. Guest and signed-in sessions identify people who use the app.
Require an identity
Use requireIdentity() when an operation allows guests and signed-in accounts. It returns an identity with a non-null userId, or rejects the request.
const { userId } = ctx.auth.requireIdentity();
const todos = await ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
Use requireSignedIn() when an operation requires an account. It rejects guests and requests without a session.
const { userId } = ctx.auth.requireSignedIn();
Always filter user-owned reads and check row ownership before updates or deletes. The auth guard identifies the caller. It does not authorize access to every row.
Custom endpoint credentials
On custom endpoints, Authorization belongs to your app. Lakebed passes it to req.headers.get("authorization") unchanged, so webhooks can use Bearer or Basic credentials. That header does not establish a Lakebed identity on a custom endpoint.
To supply a signed-in Lakebed identity, send the raw identity token in the reserved X-Lakebed-Token header. Do not add a Bearer prefix. Both headers can appear on the same request:
X-Lakebed-Token: <Lakebed identity token>
Authorization: Bearer <app webhook token>
Lakebed verifies and removes X-Lakebed-Token before the endpoint runs. Read the identity from ctx.auth and use the auth guards. Same-origin browser requests can use their guest session cookie, but a guest does not satisfy requireSignedIn().
For a custom browser request, read the token after session setup. Send it only to your app's origin:
import { getIdentity } from "lakebed/client";
const token = getIdentity().token;
const response = await fetch("/api/status", {
headers: token ? { "X-Lakebed-Token": token } : {}
});
Built-in auth and storage routes still accept Authorization: Bearer <Lakebed identity token>. The client SDK handles credentials for its built-in calls.
getIdentity() returns { userId, token?, expired? } read from browser storage. userId is null when there is no session. expired is true when the stored token is past its lifetime, so send the request without a token and let the user sign in again.
decodeIdentityClaims(token) returns that token's IdentityClaims, or null. It reads the payload and does not check the signature, so use it for display only. Never decide access with it. The server verifies every token before it builds ctx.auth.
Require sign-in for the app
Set the policy on the capsule:
export default capsule({
auth: { requireSignIn: true },
// schema, queries, mutations, actions, and endpoints
});
Lakebed checks this policy on the server before app data operations run. Guests and requests without a session cannot run queries, mutations, actions, endpoints, or use the app's stored-file routes. The app shell and auth routes remain available so users can sign in. Client code and static assets are not secrets.
The default is requireSignIn: false. That permits guest access where the app's handlers allow it. Use requireSignedIn() inside selected handlers when only some operations require an account. Hiding a button in the client is not an access check.
Use the current SDK to change this policy, then rebuild and redeploy. To allow guests again, set auth: { requireSignIn: false }.
The app-wide policy also blocks external webhook callers that do not have a signed-in Lakebed session. For an app that accepts webhooks, leave the app-wide policy off, guard user operations, and verify webhook credentials in each webhook handler.
User references and guest upgrades
Store references to Lakebed users with userId() from lakebed/server:
import { boolean, string, table, userId } from "lakebed/server";
const todos = table({
text: string(),
done: boolean().default(false),
ownerId: userId()
}).index("by_owner", ["ownerId"]);
Fields declared with userId() follow a guest to their account after sign-in. Lakebed verifies both sessions before transfer. Your app must not accept a caller-supplied guest ID as proof. The same flow works for a new account and an existing account.
The todo starter needs no migration hook. It transfers the guest's ownerId references to the signed-in user. Existing account todos remain, and guest todos join the list. Each row keeps its own ID.
Reference transfer and an optional upgrade hook commit in one transaction. A failure leaves the guest data unchanged. Retrying the upgrade does not transfer the same guest twice. Old guest credentials cannot access the signed-in account after transfer.
Only fields declared with userId() transfer. Lakebed does not inspect plain strings, JSON text, concatenated keys, cached profile names, or external services for user IDs. Use id("table") for app table references and string() for ordinary text or external identifiers.
userId() does not create an ownership rule. A shared feed can store authorId: userId() and deliberately query all entries without an owner filter. Do not represent shared state with a fake user such as "global".
Choose how conflicting records combine
Automatic transfer preserves rows. It cannot decide which settings to keep when a guest and an existing account both have settings.
Use auth.onGuestUpgrade for that decision. The hook receives verified guestUserId and userId values. It runs before automatic reference transfer, in the same database transaction. For example, keep existing account settings and discard guest settings only when the account already has a row:
import { capsule, string, table, userId } from "lakebed/server";
export default capsule({
schema: {
settings: table({
ownerId: userId(),
theme: string()
}).index("by_owner", ["ownerId"])
},
auth: {
onGuestUpgrade: async (ctx, { guestUserId, userId }) => {
const accountSettings = await ctx.db.settings
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.first();
if (!accountSettings) {
return;
}
const guestSettings = await ctx.db.settings
.withIndex("by_owner", (q) => q.eq("ownerId", guestUserId))
.collect();
for (const settings of guestSettings) {
await ctx.db.settings.delete(settings.id);
}
}
}
});
If there are no account settings, automatic transfer keeps the guest settings. Keep this hook to database merge rules. Do not use outbound fetch in this hook. The hosted runtime rejects it. Do not send email, charge a customer, or make other external changes from the hook. Its database work can roll back or retry. External side effects cannot roll back with it.
Signed-in identity and profile data
A signed-in account has one stable authorization key. Its userId equals subject. It does not encode the deploy hostname, so a generated app URL and a custom domain resolve to the same signed-in userId. A guest upgrade changes guest references to this account ID.
ctx.auth.identityAliases lists older audience-scoped aliases. Use them only to migrate rows created before stable account IDs existed. Never key new data on an alias.
Email, emailVerified, name, and picture are profile data. A verified email can help locate a pending invitation. It is not an authorization key or evidence of access. Email changes do not change userId, and two accounts with the same email remain distinct users.
Google sign-in requests profile data by default. To sign in without requesting email, name, or picture, set requestPii to false:
<SignInWithGoogle requestPii={false} />
await signInWithGoogle({ requestPii: false });
requestProfile={false} is also supported. If both options are set, requestPii takes precedence. Lakebed exposes profile fields only after the user approves the request.
Signed-in tokens are bound to their exact origin. A bad token or unavailable verifier cannot grant signed-in access. A verifier outage does not erase the browser's stored token. Revocation applies to new requests and live subscriptions. Signing out clears private query caches. Signing in after revocation restores the same account ID, but old tokens stay invalid. Deleting an account invalidates its tokens.
Local development
npx lakebed dev uses protected guest sessions and real Google sign-in on localhost. No auth configuration is needed.
For repeatable local tests, use named guest overrides:
npx lakebed auth as alice
npx lakebed auth reset
You can also use ?lakebed_guest=alice and ?lakebed_guest=bob in separate tabs. Named overrides are test identities, not protected browser sessions. They work only in local development and cannot upgrade to an account. auth reset restores normal protected browser sessions. Test a real guest upgrade with the default session and no named override.
Upgrade an existing capsule
Rebuild and redeploy existing capsules with the current SDK. Older client bundles do not run the protected guest-session setup.
Change fields that contain Lakebed user IDs from string() to userId(). Stored values are strings. When applying the new schema, Lakebed also resolves guest references from upgrades it previously verified. It does not infer account ownership from unknown IDs. Review IDs stored in plain strings, JSON text, or external systems separately.
userId and provider are null when there is no session. Use ctx.auth.requireIdentity() or ctx.auth.requireSignedIn() before using the caller's user ID. Custom endpoint callers must send Lakebed tokens in X-Lakebed-Token. Authorization remains available for app credentials.
Old guest:local rows belonged to the shared guest. Lakebed never assigns them to the first visitor who signs in. Choose an explicit policy for that old shared data. Do not guess ownership from a name, email, or the browser that happens to visit next.
Identities from the previous external auth provider are not automatically linked to current accounts. Require explicit reauthorization or account linking for those migrations. Never infer a link from email. identityAliases covers older Lakebed account aliases, not external-provider subjects.
Restricted apps and lakebed users
Restricted access, where only people you approve can open an app, cannot be turned on in the alpha: capsule() has no option for it. For a deploy that is already restricted, npx lakebed users list, approve, deny, and remove manage who may open it, by user id or email, from the capsule directory or with a deploy id. On a public deploy these commands say there is nothing to manage.