Skip to main content
Documentation

Authentication

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

On this page

Authentication runs on the platform's auth worker, so you don't run an OAuth flow, mint JWTs, or manage sessions. The SDK ships React providers and components that wrap Better Auth, plus a verifyJwt 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> to everything inside it.

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)/.

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

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 optimistic writes to toasts (permission denials as warnings, everything else as errors). That callback is the only surface where a rejected optimistic write is reported.

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 for the "is the user signed in?" check. It updates immediately on sign-in and sign-out:

import { useAuth } from 'deepspace'

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

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

<AuthGate> props#

PropTypeDescription
fallbackReactNodeUI 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.
redirectOnSignOutstringWhere 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:

import { AuthGate } from 'deepspace'

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

Sign-in UI#

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

import { AuthOverlay, useAuth } from 'deepspace'

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

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:

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

Conditional rendering#

For small one-off bits of UI, <SignedIn> and <SignedOut> are shorthand for the isSignedIn branch:

import { SignedIn, SignedOut } from 'deepspace'

<SignedIn>
  <UserMenu />
</SignedIn>
<SignedOut>
  <SignInButton />
</SignedOut>
tsx

Signing out#

Call signOut - a thin re-export of Better Auth's client method:

import { signOut } from 'deepspace'

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

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

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:

// 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 })
})
ts

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#

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 />}
    </>
  )
}
tsx

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.

// 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
]
ts

Role is defined in src/constants.ts - extend it there to add new roles, then use them in 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:

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

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

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

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#