DEV Community

Cover image for Convex & Kinde
Shola Jegede
Shola Jegede

Posted on

Convex & Kinde

This guide outlines the Kinde-specific setup for Convex, following a flow similar to the Convex & Clerk integration but focusing on how to integrate Kinde with Convex.

It addresses many of the questions raised by the Kinde developer community, which can be found here: Kinde Community - Integrating Convex with Kinde

The tutorial provides clear, actionable steps for integrating Kinde authentication with Convex while adhering to best practices.

Kinde is an authentication platform that enables passwordless user sign-ins via methods such as magic links, SMS codes, or authenticator apps. It also supports multi-factor authentication (MFA) for added security, enterprise-level single sign-on (SSO) with SAML, and offers robust user management tools for businesses.

Example: Convex Authentication with Kinde

If you're using Next.js see the Next.js setup guide for Convex.

Get started

This guide assumes you already have a working Next.js app with Convex. If not follow the Convex Next.js Quickstart first. Then:

  • Sign up for Kinde

Sign up for a free Kinde account at kinde.com/register.

Kinde business registration page

  • Create a business in Kinde

Enter the name of your business or application.

Create a business on Kinde

  • Select your tech stack

Select the tech stack or tools that you would be using to build this application.

Select tech stack on Kinde

  • Select authentication methods

Choose how you want your users to sign in.

Select authentication methods

  • Connect your app to Kinde

Connect your Next.js application to Kinde.

Showing how to connect a next.js app to Kinde

  • Create the auth config

Copy your KINDE_ISSUER_URL from your .env.local file. Move into the convex folder and create a new file auth.config.ts with the server-side configuration for validating access tokens.

Copy your Kinde Issuer Url

Paste in the KINDE_ISSUER_URL and set the applicationID to "convex" (the value and the "aud" Claims field).

const authConfig = {
  providers: [
    {
      domain: process.env.KINDE_ISSUER_URL, // Example: https://barswype.kinde.com
      applicationID: "convex",
    },
  ]
};

export default authConfig;
Enter fullscreen mode Exit fullscreen mode
  • Set up the Convex & Kinde Webhook

In Kinde Dashboard, go to Settings > Webhooks > Click Add Webhook > Name the webhook and paste your Convex endpoint URL, e.g., https://<your-convex-app>.convex.site/kinde.

Select events to trigger, such as user.created and user.deleted.

Now back to your code. Open your convex/ folder and create a new file http.ts, then copy and paste this code:

import { httpRouter } from "convex/server";
import { internal } from "./_generated/api";
import { httpAction } from "./_generated/server";
import { jwtVerify, createRemoteJWKSet } from "jose";

type KindeEventData = {
  user: {
    id: string;
    email: string;
    first_name?: string;
    last_name?: string | null;
    is_password_reset_requested: boolean;
    is_suspended: boolean;
    organizations: {
      code: string;
      permissions: string | null;
      roles: string | null;
    }[];
    phone?: string | null;
    username?: string | null;
    image_url?: string | null;
  };
};

type KindeEvent = {
  type: string;
  data: KindeEventData;
};

const http = httpRouter();

const handleKindeWebhook = httpAction(async (ctx, request) => {
  const event = await validateKindeRequest(request);
  if (!event) {
    return new Response("Invalid request", { status: 400 });
  }

  switch (event.type) {
    case "user.created":
      await ctx.runMutation(internal.users.createUserKinde, {
        kindeId: event.data.user.id,
        email: event.data.user.email,
        username: event.data.user.first_name || ""
      });
      break;
    {/** 
    case "user.updated":
      const existingUserOnUpdate = await ctx.runQuery(
        internal.users.getUserKinde,
        { kindeId: event.data.user.id }
      );

      if (existingUserOnUpdate) {
        await ctx.runMutation(internal.users.updateUserKinde, {
          kindeId: event.data.user.id,
          email: event.data.user.email,
          username: event.data.user.first_name || ""
        });
      } else {
        console.warn(
          `No user found to update with kindeId ${event.data.user.id}.`
        );
      }
      break;
    */}
    case "user.deleted":
      const userToDelete = await ctx.runQuery(internal.users.getUserKinde, {
        kindeId: event.data.user.id,
      });

      if (userToDelete) {
        await ctx.runMutation(internal.users.deleteUserKinde, {
          kindeId: event.data.user.id,
        });
      } else {
        console.warn(
          `No user found to delete with kindeId ${event.data.user.id}.`
        );
      }
      break;
    default:
      console.warn(`Unhandled event type: ${event.type}`);
  }

  return new Response(null, { status: 200 });
});

// ===== JWT Validation =====
async function validateKindeRequest(request: Request): Promise<KindeEvent | null> {
  try {
    if (request.headers.get("content-type") !== "application/jwt") {
      console.error("Invalid Content-Type. Expected application/jwt");
      return null;
    }

    const token = await request.text(); // JWT is sent as raw text in the body.
    const JWKS_URL = `${process.env.KINDE_ISSUER_URL}/.well-known/jwks.json`;
    const JWKS = createRemoteJWKSet(new URL(JWKS_URL));

    const { payload } = await jwtVerify(token, JWKS);

    // Ensure payload contains the expected properties
    if (
      typeof payload === "object" &&
      payload !== null &&
      "type" in payload &&
      "data" in payload
    ) {
      return {
        type: payload.type as string,
        data: payload.data as KindeEventData,
      };
    } else {
      console.error("Payload does not match the expected structure");
      return null;
    }
  } catch (error) {
    console.error("JWT verification failed", error);
    return null;
  }
}

http.route({
  path: "/kinde",
  method: "POST",
  handler: handleKindeWebhook,
});

export default http;
Enter fullscreen mode Exit fullscreen mode

For a detailed guide on setting up webhooks between Kinde and Convex, refer to this post.

  • Deploy your changes

Run npx convex dev to automatically sync your configuration to your backend.

npx convex dev
Enter fullscreen mode Exit fullscreen mode
  • Install Kinde

In a new terminal window, install the Kinde Next.js library

npm install @kinde-oss/kinde-auth-nextjs
Enter fullscreen mode Exit fullscreen mode
  • Copy your Kinde environment variables

On the Kinde dashboard, click view details on your app.

Copy Kinde environment variables

Scroll down and copy your Client ID and Client secret

Copy client Id and secret in Kinde

  • Set up Kinde auth route handlers

Create the following file app/api/auth/[kindeAuth]/route.ts inside your Next.js project. Inside the file route.ts copy and paste this code:

import {handleAuth} from "@kinde-oss/kinde-auth-nextjs/server";
export const GET = handleAuth();
Enter fullscreen mode Exit fullscreen mode

This will handle Kinde Auth endpoints in your Next.js app.

Important! The Kinde SDK relies on this file exisiting in this location as specified above.

  • Configure a new provider for Convex and Kinde integration

Create a providers folder in your root directory and add a new file ConvexKindeProvider.tsx. This provider will integrate Convex with Kinde and wrap your entire app.

Inside ConvexKindeProvider.tsx, wrap the ConvexProvider with KindeProvider, and use useKindeAuth to fetch the authentication token and pass it to Convex.

Paste the domain, clientId and redirectUri as props to KindeProvider.

"use client";

import { ReactNode, useEffect } from "react";
import { KindeProvider, useKindeAuth } from "@kinde-oss/kinde-auth-nextjs";
import { ConvexProvider, ConvexReactClient, AuthTokenFetcher } from "convex/react";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL as string);

const ConvexKindeProvider = ({ children }: { children: ReactNode }) => {
  const { getToken } = useKindeAuth();

  useEffect(() => {
    const fetchToken: AuthTokenFetcher = async () => {
      const token = await getToken();
      return token || null;
    };

    if (typeof getToken === "function") {
      convex.setAuth(fetchToken);
    }
  }, [getToken]);

  return (
    <KindeProvider
      domain={process.env.NEXT_PUBLIC_KINDE_DOMAIN as string}
      clientId={process.env.NEXT_PUBLIC_KINDE_CLIENT_ID as string}
      redirectUri={process.env.NEXT_PUBLIC_KINDE_REDIRECT_URI as string}
    >
      <ConvexProvider client={convex}>{children}</ConvexProvider>
    </KindeProvider>
  );
};

export default ConvexKindeProvider;
Enter fullscreen mode Exit fullscreen mode

Import your configured ConvexKindeProvider.tsx to your main layout.tsx file.

import type { Metadata } from "next";
import "./globals.css";
import ConvexKindeProvider from "@/providers/ConvexKindeProvider";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Kinde and Convex Demo",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ConvexKindeProvider>
      <html lang="en">
        <body>
          {children}
        </body>
      </html>
    </ConvexKindeProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • Show UI based on authentication state

You can control which UI is shown when the user is signed in or signed out with the provided components from "convex/react" and "@kinde-oss/kinde-auth-nextjs".

To get started create a shell that will let the user sign in and sign out.

Because the DisplayContent component is a chold of Authenticated, within it and any of its children authentication is guaranteed and Convex queries can require it.

"use client";

import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";
import {
  RegisterLink,
  LoginLink,
  LogoutLink,
} from "@kinde-oss/kinde-auth-nextjs/components";
import { Authenticated, Unauthenticated, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

function App() {
  const { isAuthenticated, getUser } = useKindeBrowserClient();
  const user = getUser();

  return (
    <main>
      <Unauthenticated>
        <LoginLink postLoginRedirectURL="/dashboard">Sign in</LoginLink>
        <RegisterLink postLoginRedirectURL="/welcome">Sign up</RegisterLink>
      </Unauthenticated>

      <Authenticated>
        {isAuthenticated && user ? (
          <div>
            <p>Name: {user.given_name} {user.family_name}</p>
            <p>Email: {user.email}</p>
            <p>Phone: {user.phone_number}</p>
          </div>
        ) : null}

        <DisplayContent />
        <LogoutLink>Log out</LogoutLink>
      </Authenticated>
    </main>
  );
}

function DisplayContent() {
  const { user } = useKindeBrowserClient();
  const files = useQuery(api.files.getForCurrentUser, {
    kindeId: user?.id,
  });

  return <div>Authenticated content: {files?.length}</div>;
}

export default App;
Enter fullscreen mode Exit fullscreen mode
  • Use authentication state in your Convex functions

If the client is authenticated, you can access the information stored in the JWT sent by Kinde via ctx.auth.getUserIdentity.

If the client isn't authenticated, ctx.auth.getUserIdentity will return null.

Make sure that the component calling this query is a child of Authenticated from "convex/react", otherwise it will throw on page load.

import { query } from "./_generated/server";

export const getForCurrentUser = query({
  args: { kindeId: v.string() },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (identity === null) {
      throw new Error("Not authenticated");
    }
    const files = await ctx.db
      .query("files")
      .filter((q) => q.eq(q.field("kindeId"), args.kindeId))
      .collect();
    if (!files) {
      throw new Error("No files found for this user");
    }
    return files;
  },
});
Enter fullscreen mode Exit fullscreen mode

Login and logout Flows

Now that you have everything set up, you can use the LoginLink component to create a login flow for your app.

If you would prefer to configure custom sign-in/sign-up forms for your app, see this post.

import {LoginLink} from "@kinde-oss/kinde-auth-nextjs/components";

<LoginLink>Sign in</LoginLink>
Enter fullscreen mode Exit fullscreen mode

To enable a logout flow you can use the LogoutLink component to enable a user seamlessly logout of your app.

import {LogoutLink} from "@kinde-oss/kinde-auth-nextjs/components";

<LogoutLink>Log out</LogoutLink>
Enter fullscreen mode Exit fullscreen mode

Logged-in and logged-out views

Use the useConvexAuth() hook instead of Kinde's useKindeBrowserClient hook when you need to check whether the user is logged in or not. the useConvexAuth hook makes sure that the browser has fetched the auth token needed to make authenticated requests to your Convex backend, and that the Convex backend has validated it:

import { useConvexAuth } from "convex/react";

function App() {
  const { isLoading, isAuthenticated } = useConvexAuth();

  return (
    <div className="App">
      {isAuthenticated ? "Logged in" : "Logged out or still loading"}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

User information in functions

See Auth in Functions to learn about how to access information about the authenticated user in your queries, mutations and actions.

See Storing Users in the Convex Database to learn about how to store user information in the Convex database.

User information in Next.js

You can access information about the authenticated user like their name and email address from Kinde's useKindeBrowserClient or getKindeServerSession hooks. See the User information object for the list of available fields:

"use client";

import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";

export default function Hero() {
  const { user } = useKindeBrowserClient();
  return <span>Logged in as {user?.given_name} {user?.family_name}</span>;
};
Enter fullscreen mode Exit fullscreen mode

Configuring dev and prod instances

To configure a different Kinde instance between your Convex development and production deployments you can use environment variables configured on the Convex dashboard.

Configuring the backend

Kinde's default configurations are set to a production environment. To use a custom domain instead of the issued <your_app>.kinde.com domain, see this guide.

Development configuration

Open the settings for your dev deployment on the Convex dashboard and add all the variables from your .env.local there:

Convex dashboard with environment variables

Production configuration

Similarly on the convex dashboard switch to your production deployment in the left side menu and set the variables from your .env.local there.

Now switch to the new configuration by running npx convex deploy.

npx convex deploy
Enter fullscreen mode Exit fullscreen mode

Deploying your Next.js App

Set the environment variable in your production environment depending on your hosting platform. See Hosting.

Debugging authentication

If a user goes through the Kinde register or login flow successfully, and after being saved to your Convex database and redirected back to your page useConvexAuth gives isAuthenticated: false, it's possible that your backend isn't correctly configured.

The auth.config.ts files in your convex/ directory contains a list of configured authentication providers. You must run npx convex dev or npx convex deploy after adding a new provider to sync the configuration to your backend.

For more thorough debugging steps, see Debugging Authentication.

Under the hood

The authentication flow looks like this under the hood:

  1. The user clicks a register or login button.
  2. The user is redirected to a hosted Kinde page where they sign up or log in via whatever method you configure in Kinde.
  3. After a successful sign up or login, their details are sent through a webhook and stored securely in Convex, after which they are redirected immediately back to your page, or a different page which you configure via the Kinde postLoginRedirectURL prop.
  4. The KindeProvider now knows that the user is authenticated.
  5. The useKindeAuth and AuthTokenFetcher fetches an auth token from Kinde.
  6. Then the react useEffect hook sets this token to a setAuth instance of Convex.
  7. The ConvexProvider then passes this token down to your Convex backend to validate.
  8. Your Convex backend retrieves the domain, clientId and redirectUri from Kinde to check that the token's signature is valid.
  9. The ConvexProvider is notified of successful authentication, and now your entire application knows that the user is authenticated with Convex. useConvexAuth return isAuthenticated: true and the Authenticated component renders its children.

The configuration in the ConvexKindeProvider.tsx file takes care of refetching the token when needed to make sure the user stays authenticated with your backend.

Top comments (0)