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, exchanges the .omg.dev session cookie for a short-lived JWT. |
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
<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
npm run dev runs without auth. The provider falls back to no-auth
(token = null), useUser() returns null, and scoped collections 401.
To exercise the real flow, deploy to <slug>.apps.omg.dev.