User accounts & auth
env.AUTH is a complete authentication system for your app's own end-users, email + password, sessions, email verification, password reset, and email-based two-factor. It manages its own tables and session cookie; you call methods and return the responses it hands back.
Sign up & sign in
signUp and signIn return a result object. On success it includes a response, a Response with the session cookie already set. Return it (or copy its Set-Cookie) so the browser is signed in:
// api/login.js
export default {
async fetch(request, env, ctx) {
const { email, password } = await request.json();
const r = await env.AUTH.signIn({ email, password });
if (!r.ok) return Response.json({ error: r.error }, { status: 401 });
if (r.requires2fa) {
// a code was emailed; collect it, then call verifyTwoFactorOtp
await env.AUTH.sendTwoFactorOtp({ otpToken: r.otpToken });
return Response.json({ requires2fa: true, otpToken: r.otpToken });
}
return r.response; // session cookie set
},
};signUp({ email, password, name? }) works the same way and returns { ok, user, response }.
Reading the current user
On any request, read who's signed in. getUser returns the user or null; requireUser throws a 401 response if there's no session:
const user = await env.AUTH.getUser(request);
// => { id, email, name, emailVerified, twoFactorEnabled, createdAt } | nullScope your data queries to user.id.
Two-factor (email OTP)
When a user has 2FA enabled, signIn returns requires2fa: true and an otpToken instead of a session. Email the code, then verify it to complete sign-in:
sendTwoFactorOtp({ otpToken }), email the code.verifyTwoFactorOtp({ otpToken, code }), returns{ ok, user, response }; return theresponseto set the session.enableTwoFactor(request)/disableTwoFactor(request, { password }).
Sign in with Microsoft or Google
On top of email + password, env.AUTH can show a “Sign in with Microsoft” or “Sign in with Google” button on its sign-in screen, handy when your users already live in Microsoft 365 / Entra ID or Google Workspace and shouldn't need a separate password. It's an additional front door into the same env.AUTH, not a separate system.
A user who signs in this way becomes an ordinary env.AUTH account, the same _cr_users record you'd get from email + password. If an account with that email already exists, the sign-in links to it; otherwise a new, pre-verified one is created. Everything downstream is identical: getUser/requireUser return the same user, the same session cookie is set, and your code never has to know how they signed in. The provider's own MFA stands in for the password and email-OTP steps.
Enable a provider by setting its environment variables on the app:
- Microsoft: register
https://<your-app-domain>/_cr/auth/sso/microsoft/callbackas a Web redirect URI in your Entra app registration, then setCR_AUTH_MS_CLIENT_IDandCR_AUTH_MS_CLIENT_SECRET(as a secret). OptionalCR_AUTH_MS_TENANTlocks sign-in to one organization. - Google: register
https://<your-app-domain>/_cr/auth/sso/google/callbackas an authorized redirect URI, then setCR_AUTH_GOOGLE_CLIENT_IDandCR_AUTH_GOOGLE_CLIENT_SECRET(as a secret). OptionalCR_AUTH_GOOGLE_HDlocks to one Workspace domain.
Then link a button on your own login page straight to the start URL, no OAuth code to write:
<a href="/_cr/auth/sso/microsoft/start?next=/dashboard">
Sign in with Microsoft
</a>/_cr/auth/sso/<provider>/start sends the user to the provider and comes back to /_cr/auth/sso/<provider>/callback, which establishes a normal env.AUTH session and redirects to next (a path on your app; defaults to /). If sign-in is refused, e.g. an unverified email, or an existing password account it isn't allowed to link, the user is sent to next with a ?cr_sso_error=… query param your page can read to show a message. It works whether or not your app uses the built-in MCP server; existing env.AUTH users are preserved (matched by email).
CR_AUTH_* config powers both your app's pages and (if you use it) the built-in MCP sign-in screen, which also renders these buttons automatically.<branch>--<app>.cloudrizz.app) can't complete a Microsoft/ Google sign-in, and you'd be stuck at the login screen. You don't need to register preview URLs: on the app's dashboard (Deployments, or the “Your staging” card) each successful preview deploy has an “Open preview (signed in)” button that drops you into the preview already authenticated, past the login gate, even for an SSO-only app. It's preview-only (never production) and the link is valid for about an hour.Reviewing previews (and SSO)
Each branch gets its own preview deploy, a disposable copy with its own sandbox database, kept separate from your live app and its real data. By default, previews don't accept real external sign-ins: your customers can't log in to a preview with their Microsoft or Google account, and no real end-user data lands in the sandbox. That isolation is deliberate, it keeps real people's accounts and personal data out of unreviewed, in-progress code.
So how do you review a login-gated branch? Use the built-in preview inspector. On the app's dashboard (Deployments, or the “Your staging” card) every successful preview deploy has an “Open preview (signed in)” button that drops you into the preview already authenticated, past the login gate, even for an SSO-only app. It signs you in as yourself (your own email), so any domain or role checks your app makes, e.g. an @company.no gate, still pass. It's preview-only, never production.
Letting real reviewers sign in (opt-in). Sometimes you want a handful of real people to try a branch with their own Microsoft or Google account, a client signing off on a change, say. An app admin can turn this on from the app page, under the “Real SSO on previews” card. It's off by default and switching it on asks you to agree to a short data-protection disclaimer (we record who agreed and when). You also list the reviewer emails allowed in, at least one is required.
- No per-branch setup with your provider. You don't register preview URLs with Entra or Google. Sign-in is routed back through your already-registered production address and the session is handed to the preview, so the redirect URI you registered once keeps working for every branch.
- Only your allowlist gets in. Just the reviewer emails you list can sign in; your existing tenant / domain lock still applies on top. Everyone else is turned away.
- Data stays in the sandbox. A reviewer's session and anything they enter live in the preview sandbox database, never in your production data.
- It takes effect after a redeploy. The behavior is baked in at deploy time, so redeploy both your production app and the branch preview after enabling (or changing the allowlist) for it to apply.
Password reset
A two-step flow. First the user asks for a reset; env.AUTH emails them a code. Then they enter that code plus a new password on a page you build, and you call resetPassword.
env.AUTH emails a code, not a magic link, it can't know your app's URL. So you expose your own reset page (e.g. /auth/reset) where the user pastes the code. The code expires in 1 hour and is single-use.- Request a reset. Your “forgot password” form posts the email here.
requestPasswordResetalways returns{ ok: true }, even if no such account exists, so you can never leak which emails are registered. Show the same neutral “check your email” message either way.// api/forgot-password.js export default { async fetch(request, env) { const { email } = await request.json(); await env.AUTH.requestPasswordReset(email); // Always identical, never reveal whether the account exists. return Response.json({ ok: true }); }, }; - Reset with the code. Your reset page collects the code + new password and posts them here. On success, every existing session for that user is revoked, a reset kicks any attacker off every device, and the user signs in fresh.
// api/reset-password.js (your /auth/reset page posts here) export default { async fetch(request, env) { const { token, newPassword } = await request.json(); const r = await env.AUTH.resetPassword({ token, newPassword }); if (!r.ok) { // "weak_password" , newPassword under 8 characters // "invalid_token" , wrong, expired, or already-used code return Response.json({ error: r.error }, { status: 400 }); } return Response.json({ ok: true }); // old sessions gone; user signs in again }, };
Other methods
signOut(request)→ aResponsethat clears the session cookie.updateUser(request, { name }), update profile fields.changePassword(request, { current, new }), revokes other sessions on success.sendVerificationEmail(request)·verifyEmail(token).- Password reset,
requestPasswordReset(email)·resetPassword({ token, newPassword }). See Password reset above for the full flow.
Deleting accounts
Let people delete their account from inside the app. Apple and Google require it for apps with sign-up. Either call the built-in route from a “Delete account” button, or use the method in your own route after removing the user's own data:
// built-in: POST /_cr/auth/delete-account { "password": "..." }
// or in api/account.js, to delete your own rows first:
const user = await env.AUTH.requireUser(request);
await env.DB.prepare("DELETE FROM notes WHERE user_id = ?").bind(user.id).run();
const r = await env.AUTH.deleteUser(request, { password });
if (r.ok) return r.response;
// "unauthorized" = no session, "invalid_credentials" = wrong password
return Response.json({ error: r.error }, { status: r.error === "unauthorized" ? 401 : 403 });Password accounts must confirm with their password. Accounts that signed in with Microsoft, Google, Apple, Steam or Discord don't need one. Deleting removes the account, its sessions and push devices, and tells Apple to revoke Sign in with Apple.
Sign in with Apple
On the website, Apple works like Microsoft and Google: link a button to /_cr/auth/sso/apple/start?next=/. In the iPhone app the mobile kit shows Apple's native sheet with cloudrizzNative.signInWithApple(). Set these app env vars from your Apple Developer account:
CR_AUTH_APPLE_TEAM_ID,CR_AUTH_APPLE_KEY_IDandCR_AUTH_APPLE_PRIVATE_KEY(a Sign in with Apple key, kept as a secret)CR_AUTH_APPLE_BUNDLE_IDfor the iPhone app. It must be the exact bundle id the app ships with (if you change it in the mobile kit, change it here too).CR_AUTH_APPLE_SERVICES_IDfor the website. On that Services ID in your Apple account, add each domain the site uses (your*.cloudrizz.appaddress and any custom domain) and one return URL per domain:https://your-domain/_cr/auth/sso/apple/callback.
Native sign-in, if you build it yourself instead of using the mobile kit: send Apple's identity token to POST /_cr/auth/apple with { idToken, code, nonce, name }, or call env.AUTH.appleSignIn({ idToken, code, nonce, name }) in server code. The token's signature is checked against Apple's keys.
People who hide their email with Apple get a private relay address, which works like any other email. If Apple shares no email at all, the user comes back as { email: null, provider: "apple", appleId }. Apple sign-in works on the live app, not on preview links.
Players on Steam and Discord
Games need accounts for players who never type an email. Two built-in routes sign them in with the platform they play on, with no server code of your own:
POST /_cr/auth/steamwith{ ticket }: a Steam Web API ticket (hex) from the game. SetCR_AUTH_STEAM_APP_IDandCR_AUTH_STEAM_WEB_API_KEY(a publisher key, kept as a secret).POST /_cr/auth/discordwith{ code }: the code from the Discord Activity'sauthorize()call. SetCR_AUTH_DISCORD_CLIENT_IDandCR_AUTH_DISCORD_CLIENT_SECRET. The reply also carriesdiscordAccessTokenfor the SDK'sauthenticate().
Both return { user, token }. The same methods exist in server code as env.AUTH.steamSignIn({ ticket }) and env.AUTH.discordSignIn({ code }). These players come back from getUser as { id, name, provider, steamId | discordId } with email: null.
CR_AUTH_GAME_RATE_PER_IP and CR_AUTH_GAME_RATE_PER_APP.How the built-in sign-in routes answer: 200 with { user, token }; 401 for an invalid ticket, code or token; 429 with retryAfter (and a Retry-After header) when over the limit; 501 *_not_configured when the env vars are missing; 502 *_unavailable when Steam, Discord or Apple can't be reached.
Bearer sessions. A desktop game or a Discord iframe can't rely on the session cookie, so every session token also works as Authorization: Bearer <token>. getUser, requireUser and signOut check the cookie first, then the header. The game template wires all of this up for you.
Sessions & storage
Sessions use an HttpOnly; Secure; SameSite=Lax cookie with a 30-day lifetime. The same token also works as a Bearer header (see above). The mobile kit sends x-cr-client: native; sign-in answers to such requests also carry the token in the body and an x-cr-session-token header. WebSocket and EventSource requests can't send headers, so for those two only a ?token= in the URL is accepted too. User and session data live in reserved tables in your app's database (_cr_users, _cr_sessions, _cr_email_tokens, _cr_auth_rate, _cr_auth_apple_tokens, _cr_push_devices); don't touch these directly (see Database).