omg/docs
SDK

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

LayerWhat 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/serverVerifies 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.

Quick start — bundled <VibesLogin>Live demo

Sign in

Sign in to continue.

or

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.json

That 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>
After installing via shadcn addLive demo

Sign in

Sign in to continue.

or

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 appId config — <VibesAuthProvider> derives it from window.location.host (<slug>.apps.omg.dev<slug>).
  • No CORS setup — auth.omg.dev allows every *.apps.omg.dev subdomain via a wildcard allowlist.
  • No JWT plumbing — useCollection reads the token from context and attaches it to every request. Custom fetch to your own functions/<name>.ts? Pull token off useAuth() and add the header yourself, or call vibesFetch(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.