# Authentication

Public, gated, and mixed auth configurations for DeepSpace apps.

Authentication runs on the platform's [auth worker](/concepts/architecture#talking-to-platform-workers), so you don't run an OAuth flow, mint JWTs, or manage sessions. The SDK ships React providers and components that wrap [Better Auth](https://www.better-auth.com/), plus a [`verifyJwt`](/sdk-reference/worker/auth#verifyjwt-config-token) helper for your worker.

## Auth models

DeepSpace apps usually fall into one of three shapes. The scaffold ships the mixed model - `src/pages/(app)/(protected)/_layout.tsx` applies [`<AuthGate>`](/sdk-reference/client/auth#authgate-fallback-redirectonsignout) to everything inside it.

Mixed (default)

The scaffold uses two nested route groups. `(app)/` supplies the auth and realtime providers; `(protected)/` inside it additionally requires sign-in.

```
src/pages/
  index.tsx                     static landing (/) - no providers
  (app)/
    _layout.tsx                 DeepSpaceAuthProvider + RecordProvider
    home.tsx                    dynamic, public (/home)
    (protected)/
      _layout.tsx               <AuthGate><Outlet /></AuthGate>
      settings.tsx              gated (/settings)
      // add gated pages here
```

Folders in literal parentheses are generouted route groups - they apply a layout without appearing in the URL. Adding a gated page is a one-file change: drop it inside `src/pages/(app)/(protected)/`.

**Put gated pages in the existing group - don't create a new one.** `(protected)` is not a name the router understands; it gates its children purely because that specific folder contains a `_layout.tsx` mounting `<AuthGate>`.

A page you place at `src/pages/(protected)/foo.tsx` (top level, outside `(app)/`) forms a *different* group with no layout at all. It is **publicly reachable**, and because it also sits outside `(app)/_layout.tsx` it has no `<RecordProvider>`, so `useQuery` / `useMutations` fail there too.

**Best for:** consumer apps with a public landing or marketing surface plus an authenticated app behind sign-in.

Fully gated

Two edits to the scaffold's `src/pages/(app)/_layout.tsx`: wrap [`AuthBoot`](#the-authboot-helper) with [`<AuthGate>`](/sdk-reference/client/auth#authgate-fallback-redirectonsignout), and drop `allowAnonymous` from the [`<RecordProvider>`](/sdk-reference/client/records#recordprovider) inside `AuthBoot` - inside the gate the client is always signed in, so the anonymous WebSocket path is dead code:

```tsx
// src/pages/(app)/_layout.tsx
export default function AppLayout() {
  return (
    <DeepSpaceAuthProvider>
      <AuthGate>
        <AuthBoot>
          <Navigation />
          <main><Outlet /></main>
        </AuthBoot>
      </AuthGate>
    </DeepSpaceAuthProvider>
  )
}
```

Pages left at the top level of `src/pages/` stay static and ungated - move them under `(app)/` if every route must require sign-in.

**Gate the server too.** `<AuthGate>` gates the UI and the SDK client, not the Worker: the scaffold's `wsRoute` helper in `src/server/realtime-routes.ts` deliberately accepts tokenless WebSocket connections so that apps with anonymous viewers work out of the box. For a fully private app, replace its optional-token block with a hard requirement:

```ts
// src/server/realtime-routes.ts - inside wsRoute(), replacing the optional-token block
if (!token) return new Response('Unauthorized', { status: 401 })
const auth = (await verifyJwt(jwtConfig(c.env), token)).result
if (!auth) return new Response('Unauthorized', { status: 401 })
```

That single edit gates every route that uses the helper.

**Never treat `<AuthGate>` as server authorization.** It stops the UI from rendering, not the WebSocket from upgrading - anyone can open a socket against `/ws/:roomId` directly. The server boundary is `wsRoute`'s token check plus the `'*'` rules in your [permissions](/concepts/permissions).

**Best for:** internal tools, paid SaaS, anything where every route requires sign-in.

Fully public

Delete the scaffold's `src/pages/(app)/(protected)/` folder (or remove the `<AuthGate>` from its `_layout.tsx`) so no route is gated. Keep `<RecordProvider allowAnonymous>` - it's already on in the scaffold's `(app)/_layout.tsx`. Signed-out visitors get the records that the `'*'` role rule in [permissions](/concepts/permissions) grants read access to.

**Best for:** marketing sites, public catalogs, read-only directories.

## The `AuthBoot` helper

The scaffold's `(app)/_layout.tsx` mounts the data layer through a local `AuthBoot` helper defined in that file. It is **not** the SDK's `<AuthGate>`, and it gates nothing. Its contract:

* It waits for `useAuthStatus().isLoaded`, so the data layer always mounts with resolved auth state. While the session check is in flight it renders a fixed theme-colored panel, not a spinner.
* It then mounts `<RecordProvider allowAnonymous>` and `<RecordScope>` for signed-in **and** signed-out users - public pages render inside it.
* Its `onWriteError` wiring routes server-rejected fire-and-forget writes to toasts (`permission` denials as warnings, everything else as errors). That callback is the reporting surface for rejected plain writes; use a `*Confirmed` mutation when the caller needs an awaited result.

Keep `AuthBoot` and its `onWriteError` wiring when you customize the layout. Express gating with `<AuthGate>` - route-scoped in `(protected)/_layout.tsx`, or around `AuthBoot` for a fully gated app - rather than by conditioning `AuthBoot` itself.

## Auth-state checks in components

Use [`useAuth().isSignedIn`](/sdk-reference/client/auth#useauth-authstate) for the "is the user signed in?" check. It updates immediately on sign-in and sign-out:

```tsx
import { useAuth } from 'deepspace'

function MyComponent() {
  const { isLoaded, isSignedIn, userId } = useAuth()

  if (!isLoaded) return <Skeleton />
  if (!isSignedIn) return <SignInPrompt />
  return <SignedInView userId={userId} />
}
```

Don't gate on [`useUser().user`](/sdk-reference/client/auth#useuser-user-isloading-refetch) truthiness alone. `useUser` returns the storage-layer profile (karma, credits, room role) and loads async, so you'll get a flash of "not signed in" while the profile fetch resolves. Use `useAuth().isSignedIn` for state checks and reach for `useUser()` only when you need profile fields.

## `<AuthGate>` props

| Prop                | Type        | Description                                                                                                                                                                                                 |
| ------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fallback`          | `ReactNode` | UI shown to first-visit signed-out users. Defaults to `<AuthOverlay />` rendered without `onClose`, which makes it non-dismissible. Not used when the user signs out mid-session - see `redirectOnSignOut`. |
| `redirectOnSignOut` | `string`    | Where the user lands when they sign out from inside the gate. Defaults to `'/'`. Triggers a full-page reload so cached state can't leak.                                                                    |

Pass a custom fallback to render something other than the default overlay:

```tsx
import { AuthGate } from 'deepspace'

<AuthGate fallback={<TeaserPage />}>
  <Dashboard />
</AuthGate>
```

## Sign-in UI

`<AuthOverlay />` is a styled modal sign-in component:

```tsx
import { AuthOverlay, useAuth } from 'deepspace'

function App() {
  const { isSignedIn } = useAuth()
  return (
    <>
      <MainContent />
      {!isSignedIn && <AuthOverlay providers={['google', 'github']} />}
    </>
  )
}
```

Render `<AuthOverlay />` without an `onClose` prop and gate on `!isSignedIn`. It auto-hides when the user signs in.

### Providers

By default `<AuthOverlay />` shows GitHub, Google, and email/password. The `providers` prop controls only the OAuth buttons - its type is `Array<'github' | 'google'>`. Email/password sign-in is always rendered:

```tsx
<AuthOverlay providers={['google']} />              // Google + email/password
<AuthOverlay providers={['google', 'github']} />    // Google + GitHub + email/password
```

The email/password field **signs in** an existing account; it is not a signup form. Public email signup is closed platform-wide, so in practice the accounts that use it are the `@deepspace.test` [test accounts](/guides/testing#provisioning-test-accounts) your Playwright specs drive. Your app's real users, and your own DeepSpace account, arrive through GitHub or Google.

Signing in **yourself**, to the CLI, is a different thing from signing in your app's users: `deepspace auth login` is browser OAuth only, and the stored session is what an agent or CI job reuses. See [login state](/cli-reference/overview#login-state).

## Conditional rendering

For small one-off bits of UI, [`<SignedIn>`](/sdk-reference/client/auth#signedin-and-signedout) and `<SignedOut>` are shorthand for the `isSignedIn` branch:

```tsx
import { SignedIn, SignedOut } from 'deepspace'

<SignedIn>
  <UserMenu />
</SignedIn>
<SignedOut>
  <SignInButton />
</SignedOut>
```

## Signing out

Call [`signOut`](/sdk-reference/client/auth#signin-signout) - a thin re-export of Better Auth's client method:

```tsx
import { signOut } from 'deepspace'

<button onClick={() => signOut()}>Sign out</button>
```

The scaffolded `Navigation.tsx` already calls `signOut()` from the avatar dropdown. Extend the existing one rather than adding a second sign-out control.

If your app requires sign-in, keep a sign-out control reachable in the signed-in UI. If you replace `Navigation.tsx`, wire `signOut()` into the new shell.

## Server-side verification

For custom API routes that aren't auto-protected, verify the JWT yourself. Add the handler to the existing Hono `app` in the scaffold's `worker.ts` - `verifyJwt` is already imported there, so the `import` line below is redundant if you're extending `worker.ts` in place:

```ts
// worker.ts
import { verifyJwt } from 'deepspace/worker'

app.get('/api/me', async (c) => {
  const auth = c.req.header('Authorization') ?? ''
  const token = auth.replace(/^Bearer\s+/i, '')

  const outcome = await verifyJwt({
    publicKey: c.env.AUTH_JWT_PUBLIC_KEY,
    issuer: c.env.AUTH_JWT_ISSUER,
  }, token)

  if (!outcome.result) return c.json({ error: 'unauthorized' }, 401)
  return c.json({ userId: outcome.result.userId, claims: outcome.result.claims })
})
```

`verifyJwt` never throws. It returns `{ result, error?, debug? }`: `result` is `{ userId, claims }` on success or `null` on failure, `error` is the underlying jose error, and `debug` is the decoded `iss`/`aud`/`azp`/`exp` for log lines. Always check `result` before reading the subject.

The scaffold's `wsRoute` handler already calls `verifyJwt` for every WebSocket upgrade, so you only need this pattern for custom HTTP routes.

## Common patterns

### Landing page with an "open app" CTA

```tsx
import { Link } from 'react-router-dom'
import { useAuth } from 'deepspace'

function Landing() {
  const { isSignedIn } = useAuth()
  return (
    <>
      <Hero />
      <Pricing />
      {isSignedIn
        ? <Link to="/dashboard">Open the app</Link>
        : <SignInButton />}
    </>
  )
}
```

### Conditional nav links

The scaffold's `Navigation.tsx` filters `src/nav.ts` by the user's room role. Omit `roles` to show an item to everyone; admins see everything regardless.

```ts
// src/nav.ts
import type { Role } from './constants'

export interface NavItem {
  path: string
  label: string
  roles?: Role[]
}

export const nav: NavItem[] = [
  { path: '/home', label: 'Home' },                    // visible to everyone
  { path: '/settings', label: 'Settings' },            // visible to everyone
  { path: '/admin', label: 'Admin', roles: ['admin'] },// admin-only
]
```

`Role` is defined in `src/constants.ts` - extend it there to add new roles, then use them in [permissions](/concepts/permissions).

### Hiding nav on the landing route

If your landing page has its own header, gate the global `<Navigation />` on `useLocation()` inside the scaffold's existing `_app.tsx`. Keep the surrounding providers - only the `<Navigation />` line changes:

```tsx
// src/pages/_app.tsx - inside the existing App() return
import { useLocation } from 'react-router-dom'

const isLanding = useLocation().pathname === '/'

// ...replace <Navigation /> with:
{!isLanding && <Navigation />}
```

## Troubleshooting

\`useToast must be used within ToastProvider\` on import

The scaffold's `_app.tsx` wraps the tree in the **local** `ToastProvider` (from `src/components/ui`), not the SDK's. Import `useToast` from `../components/ui`, not from `deepspace`. Mixing the two contexts produces this error.

Page is blank for signed-out visitors

A `<RecordProvider>` without `allowAnonymous` renders **nothing** when the visitor is signed out. On localhost the SDK swaps in a signed-out diagnostic box that names exactly this cause; a deployed app just shows a blank page. Either add `allowAnonymous` to make the page publicly viewable, or gate the route behind `<AuthGate>` so signed-out visitors get the sign-in fallback instead of nothing.

Safari shows the user as signed-out even after sign-in

Safari refuses to set `__Secure-` cookies on `localhost` because the attribute requires HTTPS; Chrome is more lenient. Test against `https://` URLs (or deploy) when verifying Safari behavior.

Sign-in popup opens but never returns to the app

The OAuth flow opens the auth worker at the platform domain, completes sign-in, then redirects back to your app. If the redirect doesn't fire, it's almost always a cookie or HTTPS issue (see above) - open the auth worker's tab and check the browser console.

## Next steps

* [Permissions](/concepts/permissions) - how role-based access control works on collections.
* [Server actions](/guides/server-actions) - privileged worker code that bypasses user RBAC.
* [Auth reference](/sdk-reference/client/auth) - all auth hooks, providers, and components.
* [Worker auth reference](/sdk-reference/worker/auth) - `verifyJwt` and HMAC primitives.
