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 } | null

Scope 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 the response to 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/callback as a Web redirect URI in your Entra app registration, then set CR_AUTH_MS_CLIENT_ID and CR_AUTH_MS_CLIENT_SECRET (as a secret). Optional CR_AUTH_MS_TENANT locks sign-in to one organization.
  • Google: register https://<your-app-domain>/_cr/auth/sso/google/callback as an authorized redirect URI, then set CR_AUTH_GOOGLE_CLIENT_ID and CR_AUTH_GOOGLE_CLIENT_SECRET (as a secret). Optional CR_AUTH_GOOGLE_HD locks 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).

The full Entra step-by-step, single-tenant vs multi-tenant (many-customers) and the optional claims a SaaS app needs so existing accounts link safely, is on App as an MCP server. The same 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.
Testing an SSO-gated app in preview. SSO callbacks only work on redirect URIs you registered with the provider, your production domain, so the per-preview URL (e.g. <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.
A data-protection note. Real SSO on previews is off by default on purpose. A preview runs code that hasn't shipped yet, in a disposable sandbox that's less governed than production, so letting real people sign in there, with the personal data that comes with them, is a genuine data-protection risk. Keep the allowlist tight, and wherever you can, prefer dedicated test accounts over real user accounts. If you do enable it, you're responsible as the data controller for making sure your privacy notice and any data-processing agreement cover reviewing in a preview environment.

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.
  1. Request a reset. Your “forgot password” form posts the email here. requestPasswordReset always 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 });
      },
    };
  2. 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) → a Response that 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_ID and CR_AUTH_APPLE_PRIVATE_KEY (a Sign in with Apple key, kept as a secret)
  • CR_AUTH_APPLE_BUNDLE_ID for 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_ID for the website. On that Services ID in your Apple account, add each domain the site uses (your *.cloudrizz.app address 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/steam with { ticket }: a Steam Web API ticket (hex) from the game. Set CR_AUTH_STEAM_APP_ID and CR_AUTH_STEAM_WEB_API_KEY (a publisher key, kept as a secret).
  • POST /_cr/auth/discord with { code }: the code from the Discord Activity's authorize() call. Set CR_AUTH_DISCORD_CLIENT_ID and CR_AUTH_DISCORD_CLIENT_SECRET. The reply also carries discordAccessToken for the SDK's authenticate().

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.

To protect your Steam, Discord and Apple credentials, these routes (and native Apple sign-in and push device registration) allow 20 attempts per minute from one network and 600 per minute for the whole app. Many phones behind one office or school network share a limit. Expecting a big launch? Raise them with the app env vars 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).

Verification, reset, and 2FA emails are sent through env.EMAIL automatically, there's nothing to configure.