Managed End-User Auth Let the platform handle sign-in for your app's users. You set an access policy; the platform serves the login/signup pages, stores the accounts and sessions, and **blocks unauthenticated visitors at the edge — they never reach your app**, so bots and anonymous traffic don't consume its resources or wake it up. This is for the end-users of your app (the people who visit it), not for your onvibe account. Don't build your own login tables for the common case — use this. Set the policy The policy is configuration: it lives in onvibe.json, alongside the allowlist and the public paths. Change it with the config cycle — get_config → edit → deploy (see onvibe://docs/config): { "version": "", "access": { "policy": "signup" } } policy who can access public anyone, no login (the default) password one shared password protects the whole app — no user accounts signup visitors must register and sign in; open registration allowlist only emails you pre-authorize can register and sign in Password mode is the one exception, because the password is a secret and secrets never go in the config file: set it with set_access_policy({ project_id, policy: "password", password: "..." }) — the only tool that still writes access config. Visitors enter the shared password at /auth/login and are let in; there are no accounts and no currentUser() identity. Changing the password signs everyone out. For allowlist, the authorized emails are part of the same file — the list is complete, so deploying it also revokes anyone you removed: { "version": "", "access": { "policy": "allowlist", "allowlist": ["ana@example.com", "bo@example.com"] } } The login pages are automatic When the policy is signup or allowlist, the platform serves sign-in and registration at /auth/login (and /auth/signup, /auth/logout). You do not route these paths — the platform intercepts exactly those three, before the request reaches your app. Everything else (including other /auth/* paths like /auth/profile) goes to your app as normal. This reservation applies only while the app has a policy other than public: a public app can use /auth/login for its own route freely. So: if you enable managed auth, don't build your own /auth/login, /auth/signup or /auth/logout. An unauthenticated request is redirected to the login page without ever hitting your app. That's the whole point: no wasted resources on traffic that isn't allowed in. Branding: logo + title The login, signup, password and reset pages show your app's logo and title, so they look like part of your app rather than a generic gate: - Logo — set it with set_logo (a full icon set is generated). Until you do, the pages show a neutral default (the app initial on a colored tile). The logo is served by the platform, so it never wakes your app. - Title — the app's display name, set via title in onvibe.json (download → edit → redeploy; see the config doc). If you don't set one, it defaults to the subdomain (the project id). Read the signed-in user The platform injects the identity as signed request headers. Use currentUser() from ./.onvibe/helpers.ts, which verifies the signature for you (a request that didn't come through the platform gate is rejected): import { currentUser } from "./.onvibe/helpers.ts"; export default async function handler(req: Request): Promise { const user = await currentUser(req); // { id, email } null if (!user) return new Response("Unauthorized", { status: 401 }); // user.id is a stable, immutable key — use it (not the email) to key this // person's rows in your database. return new Response(Hello ${user.email}); } currentUser() returns null for anonymous requests, for cron triggers, and for any request whose identity header isn't validly signed. Never trust a raw X-Onvibe-User header yourself — always go through currentUser(). > Note: under a plain signup/allowlist policy the gate is all-or-nothing — anonymous > visitors are redirected to login before your handler runs, so currentUser() is never null > and the 401 branch above is effectively dead. It becomes reachable only on public paths > (below), where anonymous visitors are allowed through. Mixed public + private apps (public paths) Many apps need a mix: a creator signs in to CREATE something, but anonymous visitors PARTICIPATE via a shared link (Doodle, RSVP, public polls, feedback boards). Keep the app on a signup or allowlist policy and open just the visitor-facing routes: { "version": "", "access": { "policy": "signup", "publicPaths": ["/", "/d/*", "/api/*/vote"] } } - Patterns: exact (/vote), subtree (/d/* = /d and everything under it), or per-segment glob (/api/*/vote, where * does not cross /). Omit the field (or use []) to clear it — back to all-or-nothing. - On a public path the edge does not block anonymous visitors — so currentUser() can return null there; branch on it (show a read-only/participate view vs. a full one). If the visitor does have a session, their identity is still injected, so currentUser() works too. - Every route not listed still follows the policy and is blocked at the edge. Bots hitting private routes still never wake the app. - Trade-off: serving a public route does wake the app (it has to render it). The resource saving is only on unauthorized hits to the gated routes — not on anonymous public traffic. - Only meaningful when the policy isn't public. Keep the list tight: a pattern that's too broad (e.g. /*) makes the whole app public. Manage accounts list_app_users({ project_id }) // email + signup date (never passwords) create_app_user({ project_id, email, password? }) // create an account directly update_app_user({ project_id, email, new_email?, password? }) // rename / reset password remove_app_user({ project_id, email }) // deletes the account and signs it out everywhere create_password_reset({ project_id, email }) // one-time reset link to share (valid 7 days) Importing users from another auth system To migrate an existing user base into managed auth: - If the old system used bcrypt, import the hashes directly so people keep their passwords: ``` import_app_users({ project_id, users: [{ email, password_hash: "$2b$..." }, ...] }) ``` (create_app_user also takes password_hash for a single account.) Max 500 per call. - If the old hashes are NOT bcrypt (PBKDF2, argon2, scrypt, …), you can't reuse them. Import the emails without a password — each account comes back with a one-time reset_url: ``` import_app_users({ project_id, users: [{ email }, ...] }) // → results[].reset_url ``` Share each reset_url with its user (valid 7 days); they open it, set a password, and are signed in. You can also mint one later with create_password_reset. - A common flow: read the old users straight from the app's database with query_sql, then feed them to import_app_users. Self-service "forgot password": when the platform's system email is configured, the login page shows a Forgot password? link — the user enters their email and gets a reset link by email, no action needed from you. You can still mint links yourself with create_password_reset (e.g. for imports, or if email isn't configured). Notes - Changing the policy takes effect as soon as the onvibe.json is deployed. Switching a public app to signup will send existing deep links to the login page. - Cron/scheduled tasks are unaffected. The gate only applies to public web traffic; scheduled triggers reach your app directly, so a job keeps running with any policy. A cron request has no end-user, so currentUser() returns null for it (guard cron paths with isCronRequest). - Order of checks: a paused app or one whose owner has no active subscription shows its own page (paused / subscription-required) even to signed-in users — those are checked before the auth gate, so you won't see a login page for an app that isn't serving anyway. - With allowlist, removing an email from the file (and deploying) stops future logins; call remove_app_user too to close its current sessions right away. - Sessions last 30 days. Passwords are stored hashed; the platform never exposes them. - Managed vs. self-managed: this mode keeps auth out of your app entirely. If you truly need custom profile fields or your own flows you can build auth inside the app instead — but don't run both on the same app, and prefer managed for anything standard.