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": "<keep as downloaded>",
"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": "<keep as downloaded>",
"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
titleinonvibe.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<Response> {
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/allowlistpolicy the gate is all-or-nothing — anonymous visitors are redirected to login before your handler runs, socurrentUser()is nevernulland the401branch 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": "<keep as downloaded>",
"access": { "policy": "signup", "publicPaths": ["/", "/d/*", "/api/*/vote"] }
}
- Patterns: exact (
/vote), subtree (/d/*=/dand 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 returnnullthere; 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, socurrentUser()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_useralso takespassword_hashfor 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:
Share eachimport_app_users({ project_id, users: [{ email }, ...] }) // → results[].reset_urlreset_urlwith its user (valid 7 days); they open it, set a password, and are signed in. You can also mint one later withcreate_password_reset. - A common flow: read the old users straight from the app's database with
query_sql, then feed them toimport_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.jsonis deployed. Switching apublicapp tosignupwill 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()returnsnullfor it (guard cron paths withisCronRequest). - 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; callremove_app_usertoo 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.