This guide walks through adding Convex Auth (email/password) to an existing Convex project. It covers backend setup, JWT keys via the Convex CLI, and a minimal React frontend pattern.
- A Convex project with
convex/and at least one deployment (runnpx convex devonce). - Node 18+.
- For frontend: React app using
convex/react(e.g. Vite, Next.js client component, etc.).
npm install @convex-dev/auth @auth/core
npm install --save-dev dotenv jose@convex-dev/auth+@auth/core: Convex Auth server and providers.dotenv: Used by the setup script to read.env.local.jose: Used by the setup script to generate JWT key pairs.
Create or update these files under convex/.
Add the auth tables:
import { defineSchema } from "convex/server";
import { authTables } from "@convex-dev/auth/server";
const schema = defineSchema({
...authTables,
// Add your other tables here
});
export default schema;export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
};Email/password with optional name on sign-up:
import { convexAuth } from "@convex-dev/auth/server";
import { Password } from "@convex-dev/auth/providers/Password";
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Password({
profile(params) {
return {
email: params.email as string,
name: (params.name as string) ?? undefined,
};
},
}),
],
});Convex Auth needs HTTP routes for token exchange:
import { httpRouter } from "convex/server";
import { auth } from "./auth";
const http = httpRouter();
auth.addHttpRoutes(http);
export default http;So the frontend can read the signed-in user:
import { getAuthUserId } from "@convex-dev/auth/server";
import { query } from "./_generated/server";
export const currentUser = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) return null;
return await ctx.db.get(userId);
},
});Run npx convex dev (or npx convex codegen) so the new functions and types are generated.
Convex Auth needs JWT_PRIVATE_KEY and JWKS on the Convex deployment (not in your app’s .env). Use the Convex CLI to set them.
Create setup.mjs in the project root. It will:
- Generate an RS256 key pair with
jose. - Set
JWT_PRIVATE_KEYandJWKSon your deployment vianpx convex env set(values passed via stdin so the private key’s-----BEGIN ...is not parsed as a CLI flag). - Optionally run
npx @convex-dev/authand support a “run once” flag.
Requirements: .env.local with CONVEX_DEPLOYMENT (created when you run npx convex dev). Run the script locally, not via convex dev --run-sh (which runs in the cloud and has no .env.local).
/**
* Generates JWT keys for Convex Auth and sets them via the Convex CLI.
* Requires .env.local with CONVEX_DEPLOYMENT (from `npx convex dev`).
*
* Run locally: node setup.mjs
* One-time: node setup.mjs --once
*/
import fs from "fs";
import { config as loadEnvFile } from "dotenv";
import { spawnSync } from "child_process";
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
async function generateConvexAuthKeys() {
const keys = await generateKeyPair("RS256", { extractable: true });
const privateKey = await exportPKCS8(keys.privateKey);
const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
return {
JWT_PRIVATE_KEY: privateKey.trimEnd().replace(/\n/g, " "),
JWKS: jwks,
};
}
function convexEnvSet(name, value) {
// Pass value via stdin so values starting with "--" (e.g. JWT private key)
// are not parsed as CLI options
const r = spawnSync("npx", ["convex", "env", "set", name], {
input: value,
stdio: ["pipe", "inherit", "inherit"],
shell: false,
env: process.env,
});
return r.status === 0;
}
if (!fs.existsSync(".env.local")) {
console.error("No .env.local found. Run `npx convex dev` first to create it.");
process.exit(1);
}
loadEnvFile({ path: ".env.local", override: true });
const runOnceWorkflow = process.argv.includes("--once");
if (runOnceWorkflow && process.env.SETUP_SCRIPT_RAN !== undefined) {
process.exit(0);
}
const { JWT_PRIVATE_KEY, JWKS } = await generateConvexAuthKeys();
console.log("\nSetting Convex Auth env vars (JWT_PRIVATE_KEY, JWKS) via Convex CLI...\n");
if (!convexEnvSet("JWT_PRIVATE_KEY", JWT_PRIVATE_KEY)) {
console.error("Failed to set JWT_PRIVATE_KEY. Ensure CONVEX_DEPLOYMENT is in .env.local.");
process.exit(1);
}
if (!convexEnvSet("JWKS", JWKS)) {
console.error("Failed to set JWKS.");
process.exit(1);
}
console.log("Convex Auth env vars set successfully.\n");
const result = spawnSync("npx", ["@convex-dev/auth", "--skip-git-check"], {
stdio: "inherit",
});
if (runOnceWorkflow) {
fs.writeFileSync(".env.local", "\nSETUP_SCRIPT_RAN=1\n", { flag: "a" });
}
process.exit(result.status ?? 1);Run the script locally after Convex is up, e.g. in package.json:
"scripts": {
"predev": "convex dev --until-success && node setup.mjs --once && convex dashboard"
}Do not run setup.mjs via convex dev --run-sh "node setup.mjs" — that runs in the cloud where .env.local does not exist.
To only print keys (for manual copy to Convex Dashboard → Settings → Environment Variables):
node -e "
const { exportJWK, exportPKCS8, generateKeyPair } = await import('jose');
const keys = await generateKeyPair('RS256', { extractable: true });
const privateKey = (await exportPKCS8(keys.privateKey)).trimEnd().replace(/\n/g, ' ');
const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: 'sig', ...publicKey }] });
console.log('JWT_PRIVATE_KEY=\"' + privateKey + '\"');
console.log('JWKS=' + jwks);
"Or use a small generateKeys.mjs that does the same and add a script: "convex:auth-keys": "node generateKeys.mjs".
| Where | Variable | Notes |
|---|---|---|
App (e.g. .env / .env.local) |
VITE_CONVEX_URL (or your framework’s Convex URL env) |
Convex deployment URL from dashboard. |
| App | CONVEX_DEPLOYMENT |
In .env.local; set by npx convex dev. Used by setup script so convex env set targets the right deployment. |
| Convex deployment | JWT_PRIVATE_KEY |
Set by setup.mjs via npx convex env set (stdin). |
| Convex deployment | JWKS |
Set by setup.mjs via npx convex env set (stdin). |
| Convex (optional) | SITE_URL |
For OAuth/magic-link redirects; not required for password-only. |
Replace ConvexProvider with ConvexAuthProvider and pass your Convex client:
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); // or process.env...
function App() {
return (
<ConvexAuthProvider client={convex}>
{/* rest of your app */}
</ConvexAuthProvider>
);
}Use Convex Auth’s React hooks:
- Auth state:
useConvexAuth()fromconvex/react→isAuthenticated,isLoading. - Actions:
useAuthActions()from@convex-dev/auth/react→signIn,signOut. - Current user doc:
useQuery(api.users.currentUser)(or your equivalent path).
Example hook that matches a simple “auth” API (sign in/up with email+password, sign out, current user):
import { useConvexAuth, useQuery } from "convex/react";
import { useAuthActions } from "@convex-dev/auth/react";
import { api } from "../convex/_generated/api";
export function useAuth() {
const { isAuthenticated, isLoading } = useConvexAuth();
const convexUser = useQuery(api.users.currentUser);
const { signIn: convexSignIn, signOut: convexSignOut } = useAuthActions();
const user = convexUser === undefined || convexUser === null
? null
: { id: convexUser._id, name: convexUser.name ?? "", email: convexUser.email ?? "" };
return {
user,
isAuthenticated: isAuthenticated ?? false,
isLoading: isLoading ?? true,
signIn: async (email: string, password: string) => {
const result = await convexSignIn("password", { email, password, flow: "signIn" });
if (result.redirect) window.location.href = result.redirect.toString();
},
signUp: async (name: string, email: string, password: string) => {
const result = await convexSignIn("password", { name, email, password, flow: "signUp" });
if (result.redirect) window.location.href = result.redirect.toString();
},
signOut: () => convexSignOut(),
};
}Adjust api.users.currentUser to your project’s path and the user shape to your types.
Submit with flow: "signIn":
const { signIn } = useAuthActions();
<form onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await signIn("password", {
email: formData.get("email"),
password: formData.get("password"),
flow: "signIn",
});
}}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign in</button>
</form>Include name and flow: "signUp":
await signIn("password", {
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
flow: "signUp",
});const { signOut } = useAuthActions();
// ...
await signOut();Use isLoading and isAuthenticated from useConvexAuth() (or your useAuth()):
- While
isLoading, show a loading UI. - If
!isAuthenticated, redirect to login or show a login view.
In any Convex query/mutation/action:
import { getAuthUserId } from "@convex-dev/auth/server";
import { query } from "./_generated/server";
export const myQuery = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const user = await ctx.db.get(userId);
// ...
},
});| Issue | What to check |
|---|---|
Missing JWT_PRIVATE_KEY |
Run node setup.mjs (or node setup.mjs --once) locally after npx convex dev has created .env.local. Do not run setup via convex dev --run-sh. |
unknown option '-----BEGIN...' |
You are passing the private key as a CLI argument. Use stdin for the value (see convexEnvSet in setup.mjs). |
| Setup exits without setting vars | Ensure .env.local exists and contains CONVEX_DEPLOYMENT. If you use --once, remove SETUP_SCRIPT_RAN=1 from .env.local to run setup again. |
| Sign-in does nothing / no redirect | Confirm JWT_PRIVATE_KEY and JWKS are set on the same deployment as VITE_CONVEX_URL (Convex dashboard → your deployment → Environment Variables). |