Skip to content

Instantly share code, notes, and snippets.

@dbjpanda
Created January 29, 2026 21:06
Show Gist options
  • Select an option

  • Save dbjpanda/28aff29f7a49ee747810d1ed3bbb5be0 to your computer and use it in GitHub Desktop.

Select an option

Save dbjpanda/28aff29f7a49ee747810d1ed3bbb5be0 to your computer and use it in GitHub Desktop.
Convex Authentication Setup

Adding Convex Auth to Any Convex Project

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.


1. Prerequisites

  • A Convex project with convex/ and at least one deployment (run npx convex dev once).
  • Node 18+.
  • For frontend: React app using convex/react (e.g. Vite, Next.js client component, etc.).

2. Install packages

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.

3. Convex backend

Create or update these files under convex/.

3.1 Schema — convex/schema.ts

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;

3.2 Auth config — convex/auth.config.ts

export default {
  providers: [
    {
      domain: process.env.CONVEX_SITE_URL,
      applicationID: "convex",
    },
  ],
};

3.3 Auth + Password provider — convex/auth.ts

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,
        };
      },
    }),
  ],
});

3.4 HTTP router — convex/http.ts

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;

3.5 Current user query — convex/users.ts

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.


4. JWT keys and setup script

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.

4.1 Setup script — setup.mjs

Create setup.mjs in the project root. It will:

  1. Generate an RS256 key pair with jose.
  2. Set JWT_PRIVATE_KEY and JWKS on your deployment via npx convex env set (values passed via stdin so the private key’s -----BEGIN ... is not parsed as a CLI flag).
  3. Optionally run npx @convex-dev/auth and 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);

4.2 Optional: run setup after Convex is ready

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.

4.3 Regenerating keys

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".


5. Environment variables

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.

6. Frontend (React)

6.1 Wrap app with Convex Auth

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>
  );
}

6.2 Auth state and actions

Use Convex Auth’s React hooks:

  • Auth state: useConvexAuth() from convex/reactisAuthenticated, isLoading.
  • Actions: useAuthActions() from @convex-dev/auth/reactsignIn, 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.

6.3 Sign-in form (password)

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>

6.4 Sign-up form (password)

Include name and flow: "signUp":

await signIn("password", {
  name: formData.get("name"),
  email: formData.get("email"),
  password: formData.get("password"),
  flow: "signUp",
});

6.5 Sign out

const { signOut } = useAuthActions();
// ...
await signOut();

6.6 Protecting routes

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.

7. Backend: using auth in functions

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);
    // ...
  },
});

8. Troubleshooting

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

9. References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment