Multi-user auth
Per-user data, login UI, and JWT handling for deployed Vibes apps — no extra config.
Every deployed Vibes app at <slug>.apps.omg.dev ships with multi-user
auth out of the box. The runtime verifies signed JWTs from auth.omg.dev,
the SDK attaches them automatically, and .scoped("user") collections
filter rows by _owner server-side. You don't run a backend, you don't
register an OAuth client, and you don't write a token endpoint.
TL;DR
// schema.ts — mark a collection user-scoped
import { defineSchema, collection, fields } from "@omg-dev/schema"
export default defineSchema({
collections: {
notes: collection({
fields: { title: fields.string(), body: fields.string() },
}).scoped("user"),
},
})// src/App.tsx — gate the UI on login
import { VibesAuthProvider, VibesAuthGuard, useUser } from "@omg-dev/sdk"
import { useQuery } from "@omg-dev/sdk"
interface Note { id: string; title: string; body: string; _owner: string }
function Notes() {
const user = useUser()
const { data, create } = useQuery<Note>({ api: "/api/notes", collection: "notes" })
return (
<>
<p>Hello, {user?.email}</p>
<button onClick={() => create({ title: "hi", body: "" })}>Add</button>
<ul>{data.map(n => <li key={n.id}>{n.title}</li>)}</ul>
</>
)
}
export default function App() {
return (
<VibesAuthProvider>
<VibesAuthGuard>
<Notes />
</VibesAuthGuard>
</VibesAuthProvider>
)
}That's it. Two users hitting the same deploy each see only their own
notes rows.
How it fits together
| Layer | What happens |
|---|---|
schema.ts | .scoped("user") adds an _owner column and tells the runtime to filter on it. |
<VibesAuthProvider> | Mounts the auth context, derives appId from window.location.host, and resolves one short-lived JWT from either the app session or the platform access gate. |
useCollection() | Reads the JWT from the auth context and attaches Authorization: Bearer <jwt> to every fetch. |
@omg-dev/server | Verifies the JWT via auth.omg.dev's JWKS, seeds ctxStore.userId, and the auto-CRUD handlers filter scoped tables by _owner. |
The first time a user signs in to a fresh deploy, auth.omg.dev lazily
registers the slug — no separate "create app" step.
Marking a collection user-scoped
collection({ fields: { … } }).scoped("user")The runtime adds a hidden _owner: string column at migration time. On
POST /api/<collection> the handler stamps _owner = ctx.userId; on
GET, PATCH, and DELETE it filters by _owner = ctx.userId. A
request with no JWT to a scoped collection returns 401. A PATCH or
GET /:id for another user's row returns 404 (we treat "not yours" and
"doesn't exist" identically — anything else leaks row existence).
For shared/global apps, omit .scoped("user"). Every signed-in user can
read and mutate every row. Optionally stamp authorship from the client:
const user = useUser()
await create({ title: "hi", author: user?.name })Login UI
Apps set to OMG login in project settings are already gated by the
platform before their UI loads. <VibesAuthProvider> reuses that identity, so
<VibesAuthGuard> does not show a second login form. Keep the guard in the app:
the same code still works if the app is later made public, where visitors may
need to sign in for private, user-scoped features.
<VibesAuthGuard> renders <VibesLogin> (magic-link email + passkey)
when no user is signed in. The bundled component is styled with shadcn
semantic tokens (bg-card, bg-primary, text-foreground, …) so it
picks up whatever theme the host app uses.
Sign in
Sign in to continue.
Want a polished version composed from real shadcn primitives? Install
the vibes-login block from the OMG registry:
bunx shadcn@latest add https://docs.omg.dev/r/vibes-login.jsonThat writes components/auth/vibes-login.tsx into your project,
pulls in button, input, card, and label, and adds @omg-dev/sdk
to your dependencies. Pass it to the guard via fallback:
import { VibesAuthGuard } from "@omg-dev/sdk"
import { VibesLogin } from "@/components/auth/vibes-login"
<VibesAuthGuard fallback={<VibesLogin />}>
<App />
</VibesAuthGuard>Sign in
Sign in to continue.
Either component posts to
https://auth.omg.dev/api/auth/sign-in/magic-link with
credentials: "include" so the session cookie roundtrips to .omg.dev.
After magic-link click-through the user lands back on your app and
useUser() flips non-null on the next render.
You can also drop in a fully custom UI:
<VibesAuthGuard fallback={<MyCustomLogin />}>
<App />
</VibesAuthGuard>Hooks
useUser(): VibesUser | null
useAuth(): {
user: VibesUser | null
loading: boolean
token: string | null // the JWT, for custom fetch
signOut(): Promise<void>
signInWithMagicLink(email: string, callbackURL?: string): Promise<void>
signInWithPasskey(): Promise<void>
}
interface VibesUser { id: string; email: string; name?: string }useUser is the friendly read-only hook; useAuth is the full surface.
What you DON'T need to do
- No
appIdconfig —<VibesAuthProvider>derives it fromwindow.location.host(<slug>.apps.omg.dev→<slug>). - No CORS setup —
auth.omg.devallows every*.apps.omg.devsubdomain via a wildcard allowlist. - No JWT plumbing —
useCollectionreads the token from context and attaches it to every request. Customfetchto your ownfunctions/<name>.ts? PulltokenoffuseAuth()and add the header yourself, or callvibesFetch(client). - No backend deploy — the runtime container already runs
createVibesServer({ auth: "vibes" })and verifies tokens via JWKS.
Local dev
bun run dev runs without an omg token, but localhost has no slug to derive.
Without configuration the provider falls back to app id "local" and
auth.omg.dev/token returns 404.
To exercise the real auth flow locally, set VIBES_APP_ID to a deployed slug
you own before starting Vite:
VIBES_APP_ID=my-existing-slug bun run devThe Vite plugin injects that app id into the page, and the provider mints the same slug-scoped JWT it would use on the deployed host. See Deploy from a local machine for the full local capability matrix.