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.tsxwith a defaultAppexport, as in the Convex React quickstart.- A
convex/folder. Runnpx convex devonce. It creates the development deployment, generatesconvex/_generated, and writesVITE_CONVEX_URLto.env.local.
If you do not have this app yet, follow the Convex quickstart first.
1. Install
npm install @shoojs/react2. Configure Convex auth
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.comDo 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 devrunning. It pushesconvex/auth.config.tsand your functions when they change. - Production: run
npx convex deploy.
If you change APP_ORIGIN later, push again.
4. Create the adapter
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:
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
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
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
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,
fetchAccessTokenreturnsnullandstatusbecomes"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.