Skip to content

Clerk

The Clerk integration is a thin adapter. convex-angular has no dependency on any @clerk/* package: you write a small service that exposes Clerk’s state as signals, register it under the CLERK_AUTH token, and provideClerkAuth() bridges it to Convex.

The interface your service must satisfy. Only the first three members are required.

Member Type Required Purpose
isLoaded Signal<boolean> yes true once Clerk has initialized and auth state is known. Mapped to isLoading: computed(() => !clerk.isLoaded()).
isSignedIn Signal<boolean | undefined> yes Whether Clerk reports a signed-in user. May be undefined while loading; mapped to isAuthenticated: computed(() => clerk.isSignedIn() ?? false).
getToken (options?: { template?: string; skipCache?: boolean }) => Promise<string | null> yes Returns the JWT for Convex.
sessionId Signal<string | null | undefined> no Current Clerk session id. Part of reauthVersion.
orgId Signal<string | null | undefined> no Current organization id. Part of reauthVersion.
orgRole Signal<string | null | undefined> no Current organization role. Part of reauthVersion.
sessionAudience Signal<string | null | undefined> no The session token’s aud claim. Selects the token-fetch mode (below).
error Signal<Error | undefined> no Provider-owned errors, mirrored onto injectAuth().error().
interface ClerkAuthProvider {
isLoaded: Signal<boolean>;
isSignedIn: Signal<boolean | undefined>;
getToken(options?: { template?: string; skipCache?: boolean }): Promise<string | null>;
sessionId?: Signal<string | null | undefined>;
orgId?: Signal<string | null | undefined>;
orgRole?: Signal<string | null | undefined>;
sessionAudience?: Signal<string | null | undefined>;
error?: Signal<Error | undefined>;
}
const CLERK_AUTH: InjectionToken<ClerkAuthProvider>;

Register your implementation under this token before calling provideClerkAuth(). Prefer useExisting over useClass when the service is also injected elsewhere in your app, so Angular reuses the singleton instead of constructing a second instance.

function provideClerkAuth(): EnvironmentProviders;

It registers a CONVEX_AUTH factory that adapts CLERK_AUTH, and then provideConvexAuth().

The adapter decides how to ask Clerk for a token from sessionAudience():

sessionAudience() Call made
'convex' clerk.getToken({ skipCache }) — Clerk’s native Convex integration, with no JWT template.
anything else, or absent clerk.getToken({ template: 'convex', skipCache }) — requests the 'convex' JWT template.

forceRefreshToken from Convex maps directly to skipCache: when Convex needs a genuinely fresh token it passes forceRefreshToken: true, and the adapter sets skipCache: true so Clerk bypasses its own token cache.

The adapter builds it for you:

reauthVersion: computed(() => [clerk.sessionId?.(), clerk.orgId?.(), clerk.orgRole?.()]);

Any change to that tuple makes provideConvexAuth() tear down the current auth wiring and re-run setup with a fresh token, while the user stays signed in.

orgId and orgRole cover organization switching, where the token’s claims change but the user does not. sessionId is the one that is easy to omit and expensive to miss. Signing out and back in replaces the Clerk session while isSignedIn may never dip to false for long enough to retrigger setup. Without sessionId in the tuple, Convex keeps fetching tokens for the dead session: auth looks loaded, but the app stays unauthenticated until a full page reload. Expose sessionId unless you have a specific reason not to.

clerk-auth.service.ts
import { Injectable, InjectionToken, Signal, computed, inject } from '@angular/core';
import { ClerkAuthProvider } from 'convex-angular';
/**
* These docs do not depend on `@clerk/clerk-js`, so the small slice of the
* Clerk SDK used below is declared locally. In your application these values
* come from your Clerk instance instead.
*/
interface ClerkSession {
id: string;
/** The session token's `aud` claim. */
audience?: string;
getToken(options?: { template?: string; skipCache?: boolean }): Promise<string | null>;
}
interface ClerkSdk {
loaded: Signal<boolean>;
user: Signal<{ id: string } | null>;
session: Signal<ClerkSession | null>;
organization: Signal<{ id: string; membership?: { role: string } } | null>;
}
const CLERK_SDK = new InjectionToken<ClerkSdk>('CLERK_SDK');
@Injectable({ providedIn: 'root' })
export class ClerkAuthService implements ClerkAuthProvider {
private readonly clerk = inject(CLERK_SDK);
readonly isLoaded = computed(() => this.clerk.loaded());
readonly isSignedIn = computed(() => !!this.clerk.user());
// `sessionId` is what makes a replaced session (sign out, then sign back in)
// re-run Convex auth setup instead of stranding it on the dead session.
readonly sessionId = computed(() => this.clerk.session()?.id);
readonly orgId = computed(() => this.clerk.organization()?.id);
readonly orgRole = computed(() => this.clerk.organization()?.membership?.role);
// When this is 'convex', the adapter uses Clerk's native Convex integration
// and requests no JWT template.
readonly sessionAudience = computed(() => this.clerk.session()?.audience);
async getToken(options?: { template?: string; skipCache?: boolean }): Promise<string | null> {
return (await this.clerk.session()?.getToken(options)) ?? null;
}
}

Register it in your root providers:

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { CLERK_AUTH, provideClerkAuth, provideConvex } from 'convex-angular';
import { ClerkAuthService } from './auth-clerk';
export const appConfig: ApplicationConfig = {
providers: [
provideConvex('https://example-123.convex.cloud'),
// `useExisting` so Angular reuses the singleton instead of constructing a
// second ClerkAuthService for the token.
{ provide: CLERK_AUTH, useExisting: ClerkAuthService },
// Already includes provideConvexAuth(). Do not register that as well.
provideClerkAuth(),
],
};

In a real application the SDK bindings come from Clerk directly, for example:

import { Clerk } from '@clerk/clerk-js';

Then read the state with injectAuth(), gate templates with the auth directives, and protect routes with the route guards.