Skip to main content
Convex supports custom auth providers via its custom auth feature. Ave works as a drop-in OIDC provider. This guide explains exactly what to configure, which token to pass, and what Convex does with it.

Which token to use

Always pass id_token to Convex. Never pass access_token_jwt. This is the most common mistake when integrating Ave with Convex.
Ave returns two JWTs. They look similar but serve different purposes and have different audiences:
Tokenaud claimPurpose
id_tokenYour clientIdIdentity assertion — proves who the user is
access_token_jwthttps://aveid.netAPI authorization — grants access to Ave’s resources
Convex validates the aud claim against the applicationID you configure. If you use access_token_jwt, Convex will reject it because its audience is https://aveid.net, not your client ID.

Auth config

Add Ave as a custom provider in convex/auth.config.ts:
export default {
  providers: [
    {
      domain: "https://aveid.net",
      applicationID: "your_client_id",
    },
  ],
};
domain maps to the iss (issuer) claim in the token. Ave’s issuer is https://aveid.net. Convex fetches {domain}/.well-known/openid-configuration to discover the JWKS endpoint automatically.

What Convex validates

When you call convex.setAuth(idToken) or equivalent, Convex:
  1. Fetches https://aveid.net/.well-known/openid-configuration
  2. Gets the JWKS URL from the discovery document
  3. Downloads and caches signing keys
  4. Verifies the JWT signature (RS256)
  5. Checks iss === "https://aveid.net"
  6. Checks aud === "your_client_id" (the applicationID you configured)
  7. Checks exp > now
If all checks pass, Convex considers the user authenticated and the identity is available as ctx.auth.getUserIdentity().

What claims Convex exposes

After a successful auth check, ctx.auth.getUserIdentity() returns:
{
  subject: "identity-uuid",         // id_token `sub` — Ave identity UUID
  issuer: "https://aveid.net",  // id_token `iss`
  name: "Alice Smith",              // id_token `name` (if profile scope)
  email: "[email protected]",       // id_token `email` (if email scope)
  pictureUrl: "https://...",        // id_token `picture` (if profile scope)
  // plus any other claims in the token
}
The subject is the Ave identity UUID — this is the value you should use as your primary user identifier in Convex.
The sub in the id_token is the identity UUID, not a permanent user UUID. A single Ave user can have multiple identities. If you need a stable cross-identity identifier, request the user_id scope — but note that user_id is in access_token_jwt (the uid claim), not in id_token. For most apps, the identity UUID is the right identifier.

Full implementation

1

Configure auth.config.ts

// convex/auth.config.ts
export default {
  providers: [
    {
      domain: "https://aveid.net",
      applicationID: process.env.AVE_CLIENT_ID!,
    },
  ],
};
Store your client ID in an environment variable. Do not hardcode it.
2

Complete the Ave OAuth flow

Follow the OAuth authorization code flow to get tokens. You need the openid scope at minimum — without it, Ave does not return an id_token.
import { exchangeCode } from "@ave-id/sdk";

const tokens = await exchangeCode(
  { clientId: process.env.AVE_CLIENT_ID!, redirectUri: REDIRECT_URI },
  { code, codeVerifier }
);

// id_token is only present when openid scope was requested
if (!tokens.id_token) {
  throw new Error("No id_token returned — did you include the openid scope?");
}
3

Pass id_token to Convex

import { ConvexReactClient } from "convex/react";

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

// Set the Ave id_token as the auth token
convex.setAuth(async () => tokens.id_token);
In a React app, use ConvexProviderWithAuth or the useAuth hook from your auth state to keep the token fresh.
4

Use the identity in mutations and queries

// convex/users.ts
import { mutation, query } from "./_generated/server";

export const getOrCreateUser = mutation(async (ctx) => {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error("Not authenticated");

  // identity.subject is the Ave identity UUID
  const existing = await ctx.db
    .query("users")
    .withIndex("byAveId", q => q.eq("aveIdentityId", identity.subject))
    .first();

  if (existing) return existing;

  return await ctx.db.insert("users", {
    aveIdentityId: identity.subject,
    name: identity.name ?? "Unknown",
    email: identity.email,
    imageUrl: identity.pictureUrl,
  });
});
5

Handle token refresh (recommended: Ave Session)

id_token expires (check exp claim). Do not pass a static tokens.id_token to setAuth — Convex validates exp on every request. Prefer Ave Session (AveSession + wireAveSessionToConvex) so refresh is single-flight, persisted, and runs before expiry.Manual refresh (if you are not using AveSession yet):
import { refreshToken } from "@ave-id/sdk";

const newTokens = await refreshToken(
  { clientId: CLIENT_ID, redirectUri: REDIRECT_URI },
  { refreshToken: storedRefreshToken }
);

// Persist new refresh_token immediately if the server rotated it — then:
convex.setAuth(async () => newTokens.id_token);
Default token lifetime is often about one hour (expires_in). Quick Ave has no refresh token — upgrade to a registered app with offline_access. See also Sessions still flaky below.

Sessions still flaky

If users drop out after ~one hour, or behavior feels random even with offline_access:
CauseWhat to do
Quick Ave (origin:… clientId)No refresh tokens — upgrade
Missing offline_accessRequest it — refresh tokens are not issued without it
Using static id_token in setAuthUse a function that returns a fresh token (Ave Session)
Refresh rotation not savedAfter every refresh, persist the new refresh_token before other requests run
Concurrent refreshTwo callers refreshing at once can invalidate rotated refresh tokens — use single-flight (AveSession)
Cold startEnsure hydrate() (or equivalent) finishes before authenticated Convex calls

Complete id_token payload reference

For your reference, here is the full id_token payload shape:
{
  "iss": "https://aveid.net",
  "sub": "identity-uuid",
  "aud": "your_client_id",
  "exp": 1712345678,
  "iat": 1712342078,
  "auth_time": 1712342078,
  "azp": "your_client_id",
  "sid": "user-uuid",
  "nonce": "nonce-from-request",
  "name": "Alice Smith",
  "preferred_username": "alice",
  "email": "[email protected]",
  "picture": "https://avatars.aveid.net/..."
}
Profile claims are only present when the corresponding scope was granted.

Troubleshooting

You are passing access_token_jwt instead of id_token. The access_token_jwt has aud: "https://aveid.net" — Convex expects aud === your_client_id. Always use id_token.
You did not include openid in the scope parameter of the authorization request. id_token is only issued when the openid scope is granted. Add openid to your scope list.
Verify that domain in auth.config.ts is exactly https://aveid.net. The iss claim in Ave tokens is https://aveid.net. If you have set a custom issuer via the OIDC_ISSUER environment variable in your Ave server, use that value instead.
The token may have expired, or convex.setAuth was not called before the query/mutation ran. Make sure your auth state provider calls convex.setAuth before any authenticated Convex calls.
Server clocks must be reasonably in sync. Convex allows a small tolerance. If you see exp validation failures on otherwise valid tokens, check that your backend server time is accurate.
Last modified on April 19, 2026