SUPER EARLY WIP — USE AT YOUR OWN RISK■
shoo
GitHubCOMING SOON

Use with Convex

Authenticate Convex queries and mutations with Shoo

Convex verifies Shoo tokens itself. You must give it your exact app origin as the audience.

Before you start

This guide adds Shoo to a React app that already uses Vite and Convex. You need:

  • src/App.tsx with a default App export, as in the Convex React quickstart.
  • A convex/ folder. Run npx convex dev once. It creates the development deployment, generates convex/_generated, and writes VITE_CONVEX_URL to .env.local.

If you do not have this app yet, follow the Convex quickstart first.

1. Install

npm install @shoojs/react

2. Configure Convex auth

convex/auth.config.ts
import type { AuthConfig } from "convex/server";

// Your frontend origin, not the *.convex.cloud or *.convex.site URL.
const appOrigin = process.env.APP_ORIGIN;
if (!appOrigin) throw new Error("Set APP_ORIGIN in this Convex deployment");

export default {
  providers: [
    {
      type: "customJwt",
      issuer: "https://shoo.dev",
      jwks: "https://shoo.dev/.well-known/jwks.json",
      algorithm: "ES256",
      // Required. Without it, Convex accepts tokens issued to any Shoo app.
      applicationID: `origin:${new URL(appOrigin).origin}`
    }
  ]
} satisfies AuthConfig;

applicationID is not optional. Convex skips the audience check when it is missing. A token from another Shoo app would then sign in to your backend.

3. Set the origin and push the config

Each Convex deployment has its own environment variables. Set one origin in each:

# Development deployment
npx convex env set APP_ORIGIN http://localhost:5173

# Production deployment
npx convex env --prod set APP_ORIGIN https://app.example.com

Do not set a localhost or preview origin on the production deployment. Users from different origins have different IDs. See identity and privacy.

Then push the auth config. Set APP_ORIGIN first, because the config throws without it.

  • Development: keep npx convex dev running. It pushes convex/auth.config.ts and your functions when they change.
  • Production: run npx convex deploy.

If you change APP_ORIGIN later, push again.

4. Create the adapter

src/shoo.ts
import { createShooConvexAuth } from "@shoojs/react";

export const { useAuth, signIn, signOut } = createShooConvexAuth({
  callbackPath: "/shoo/callback"
});

The adapter is safe to import during server rendering. It creates the browser client after mount.

5. Connect the provider

Replace ConvexProvider with ConvexProviderWithAuth in your entry file:

src/main.tsx
import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { useAuth } from "./shoo";

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ConvexProviderWithAuth client={convex} useAuth={useAuth}>
      <App />
    </ConvexProviderWithAuth>
  </StrictMode>
);

6. Serve the callback path

Your app must load at /shoo/callback. The Vite dev server already does this. In production, add an SPA rewrite. See SPA rewrites.

7. Read the user in Convex functions

convex/users.ts
import { query } from "./_generated/server";

export const viewer = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;

    // identity.subject is the pairwise_sub. Store this as the user ID.
    return { userId: identity.subject };
  }
});

identity.email, identity.name, and identity.pictureUrl are set only when the user agreed to share profile data.

8. Sign in and out

src/AuthButtons.tsx
import { signIn, signOut, useAuth } from "./shoo";

export function AuthButtons() {
  const { isLoading, isAuthenticated, status } = useAuth();

  if (isLoading) return null;
  if (status === "expired") return <button onClick={() => signIn()}>Sign in again</button>;
  if (!isAuthenticated) return <button onClick={() => signIn()}>Sign in</button>;
  return <button onClick={signOut}>Sign out</button>;
}

9. Show protected data

src/App.tsx
import { Authenticated, AuthLoading, Unauthenticated, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { AuthButtons } from "./AuthButtons";

export default function App() {
  return (
    <main>
      <AuthButtons />
      <AuthLoading>
        <p>Loading...</p>
      </AuthLoading>
      <Unauthenticated>
        <p>Signed out.</p>
      </Unauthenticated>
      <Authenticated>
        <Viewer />
      </Authenticated>
    </main>
  );
}

function Viewer() {
  const viewer = useQuery(api.users.viewer);

  if (viewer === undefined) return <p>Loading...</p>;
  if (viewer === null) return <p>Signed out.</p>;
  return <p>Your user ID: {viewer.userId}</p>;
}

Authenticated renders Viewer only after Convex accepts the token. Before that, useQuery would run without a user and the query would return null.

Run npm run dev and open http://localhost:5173. Click Sign in. After sign-in, the page shows your user ID from the Convex query.

Expiry and revocation

  • Tokens last one hour. When the token expires, fetchAccessToken returns null and status becomes "expired". The adapter never redirects by itself. Show a Sign in again button.
  • Convex verifies tokens offline. If a user disconnects your app in Shoo, Convex accepts their current token until it expires, up to one hour.

See tokens and sessions.

Test the audience

Before launch, confirm that Convex rejects a token from another origin. Run your app on a second port, for example npx vite --port 5174, and sign in there. The token's audience is origin:http://localhost:5174, so Convex must not accept it. The page must not show a user ID.