#convex cors httpAction

28 messages · Page 1 of 1 (latest)

bright socket
#

im tring to use an http action but when i try to use it on my front end i get a cors error:
import { httpRouter } from 'convex/server';
import { httpAction } from './_generated/server';
import { internal } from './_generated/api';
import { authKit } from './auth';

const http = httpRouter();

authKit.registerRoutes(http);
http.route({
path: '/test',
method: 'POST',
handler: httpAction(async () => {
console.log('hi');
return new Response(JSON.stringify({ error: '403' }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
})
});
from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

const response = await fetch(PUBLIC_CONVEX_SITE_URL + '/test', {
method: 'POST',
body: formData,
headers: {
Authorization: ⁨Bearer ${data.accessToken}
}
});

modest hazelBOT
#

Hi @bright socket. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:

#

Your HTTP action is only handling the POST request. The browser is sending a preflight OPTIONS request first (because you’re sending Authorization and FormData), and that OPTIONS request:

  • is not routed (no /test OPTIONS handler)
  • therefore returns a 404 without CORS headers
  • so the browser blocks the actual POST and shows the CORS error.

You need to:

  1. Handle the preflight OPTIONS request for /test.
  2. Return CORS headers on both OPTIONS and POST responses.

A minimal pattern directly from the docs looks like this (adapted to your /test route) [HTTP CORS pattern]:

import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { authKit } from "./auth";

const http = httpRouter();

authKit.registerRoutes(http);

// Actual POST
http.route({
  path: "/test",
  method: "POST",
  handler: httpAction(async () => {
    console.log("hi");
    return new Response(JSON.stringify({ error: "403" }), {
      status: 200,
      headers: {
        "Content-Type": "application/json",
        // Be as specific as you can in real apps:
        "Access-Control-Allow-Origin": "<http://localhost:5173",>
        Vary: "origin",
      },
    });
  }),
});
#

// Preflight OPTIONS for /test
http.route({
path: "/test",
method: "OPTIONS",
handler: httpAction(async (_, request) => {
const headers = request.headers;
if (
headers.get("Origin") !== null &&
headers.get("Access-Control-Request-Method") !== null &&
headers.get("Access-Control-Request-Headers") !== null
) {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "http://localhost:5173",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
},
});
} else {
return new Response();
}
}),
});

export default http;


This matches the documented pattern for CORS + preflight on HTTP actions [[Upload CORS example](<https://docs.convex.dev/file-storage/upload-files#defining-the-upload-http-action>); [HTTP CORS patterns](<https://docs.convex.dev/functions/http-actions#common-patterns>)].

Alternatively, you can use the `corsRouter` helper from `convex-helpers` to avoid writing the `OPTIONS` handler yourself, as suggested in the CORS guide and Discord thread, but the exact helper code isn’t in the provided sources [[CORS guide](<https://stack.convex.dev/a-developers-guide-to-cors#5-using-a-library>); [Discord CORS thread](<https://discord.com/channels/1019350475847499849/1406426223281705092>)].
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
#

@bright socket Kapa.ai is still learning and improving, please let me know how I did by reacting below.

bright socket
#

@modest hazel im not using cors. is using it is using the authkit of workos
import { AuthFunctions, AuthKit } from '@convex-dev/workos-authkit';
import { components, internal } from './_generated/api';
import type { DataModel } from './_generated/dataModel';

const authFunctions: AuthFunctions = internal.auth;

export const authKit = new AuthKit<DataModel>(components.workOSAuthKit, {
authFunctions
});

is him giving me cors?

modest hazelBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

AuthKit itself isn’t what’s “giving you CORS” here.

From the Convex + WorkOS AuthKit docs:

The AuthKit instance you created:

export const authKit = new AuthKit<DataModel>(components.workOSAuthKit, {
  authFunctions
});

is for syncing/auth logic; it doesn’t automatically add CORS headers to your custom /test HTTP action.

So for your /test endpoint you still need to:

  1. Add CORS headers on the POST response.
  2. Add a matching OPTIONS /test route that returns Access-Control-Allow-* headers.

AuthKit’s own routes (registered via authKit.registerRoutes(http)) handle what they need internally, but they don’t change CORS behavior for unrelated routes.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@bright socket Kapa.ai is still learning and improving, please let me know how I did by reacting below.

bright socket
#

@modest hazel now what is this error:
[Error: Could not validate token: Token expired 203 seconds ago]
31/01/2026, 20:06:07 [CONVEX H(POST /upload-image)] [ERROR] 'Image upload error:' [Error: Could not validate token: Token expired 203 seconds ago]

modest hazelBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

That error means Convex rejected the WorkOS access token you sent because it was already expired when the HTTP action ran:

Could not validate token: Token expired 203 seconds ago

With WorkOS AuthKit, tokens are short‑lived and AuthKit is supposed to refresh them before expiry and hand a fresh one to Convex. The Convex client will also proactively ask for a token ~10 seconds before it expires. [Under the hood]

This error usually appears when:

  • You manually call fetch to a Convex HTTP action and pass an old accessToken (e.g. from earlier state), instead of asking AuthKit for a fresh one.
  • Or you cache accessToken from useAccessToken() and don’t let AuthKit refresh it, which several people in the WorkOS + Convex thread hit. [Discord thread]

To fix it for your /upload-image (or /test) HTTP action:

  1. Always get a fresh token from AuthKit right before the call. In the newer pattern, people use getAccessToken() instead of a possibly stale accessToken:

    const { getAccessToken } = useAccessToken();
    
    const token = await getAccessToken(); // guaranteed fresh
    const res = await fetch(PUBLIC_CONVEX_SITE_URL + "/upload-image", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
      },
      body: formData,
    });
    

    This is exactly what one community member did to stop intermittent “unauthenticated” / expired‑token errors. [Discord fix]

  2. Don’t reuse tokens across long‑lived UI state. If you store the token in React state or a ref and reuse it later, it may be expired by the time you hit the HTTP action.

#
  1. If you’re calling the HTTP action from outside React (e.g. plain JS), make sure whatever code you use to obtain the WorkOS token is doing a refresh right before use, not returning a cached one.

If you show how you’re getting data.accessToken for the fetch(PUBLIC_CONVEX_SITE_URL + '/test') call, I can point out exactly where to swap in getAccessToken() / a fresh token.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@bright socket Kapa.ai is still learning and improving, please let me know how I did by reacting below.

bright socket
#

@modest hazel im not using the convex gave since im using sveltekit

modest hazelBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You’re right that the Convex + WorkOS AuthKit docs and examples are written for React/Next, but the server-side behavior (Convex validating the JWT and complaining that it’s expired) is the same no matter what frontend framework you use.

The error:

Could not validate token: Token expired 203 seconds ago

means: the JWT you’re sending in Authorization: Bearer ... is already expired when Convex receives it. That’s independent of React vs SvelteKit.

From the WorkOS + Convex thread, people hit the same issue when they:

  • grabbed accessToken once (e.g. from useAccessToken)
  • reused it later for API calls
  • and by then it had expired, so Convex rejected it. [WorkOS thread]

The fix they landed on was:

  • Always fetch a fresh token right before each call that hits Convex, instead of reusing a cached one.
  • In React/AuthKit they switched from using a possibly stale accessToken to using getAccessToken(), which is documented as “Get a guaranteed fresh access token. Automatically refreshes if needed.” [WorkOS fix]

For SvelteKit, the concrete API you call to get the token will differ (since you’re not using the React hooks), but the principle is the same:

  • Whatever you’re using to obtain the WorkOS access token must:
    • either automatically refresh it, or
    • be called right before the request so you don’t reuse an old token.

So for your SvelteKit code:

const response = await fetch(PUBLIC_CONVEX_SITE_URL + '/test', {
  method: 'POST',
  body: formData,
  headers: {
    Authorization: `Bearer ${data.accessToken}`
  }
});
#

data.accessToken is almost certainly coming from somewhere that can go stale (e.g. stored in a session, store, or earlier load function). You’ll want to change that to:

  • call your WorkOS/AuthKit helper that returns a fresh token at the moment of the request, then
  • pass that token into the Authorization header.

Because the knowledge sources only show the React/AuthKit APIs (useAccessToken, getAccessToken) and not a SvelteKit-specific helper, I can’t give you the exact SvelteKit code, but the server-side error will go away once the token you send is freshly obtained and not expired.

If you paste how you’re currently getting data.accessToken in SvelteKit (e.g. from a store, cookie, or a helper), I can help you adapt the “always fetch fresh token before calling Convex” pattern to that setup based on the patterns in the WorkOS thread.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@bright socket Kapa.ai is still learning and improving, please let me know how I did by reacting below.

bright socket
#

@modest hazel why when i get the auth user identity i do not get the email field ?
should i do somthing to put it on my token?

modest hazelBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You don’t see email because Convex only returns what’s actually present in the JWT / identity coming from your auth provider.

From the Convex side:

  • ctx.auth.getUserIdentity() always has tokenIdentifier, subject, and issuer.
  • All other fields (email, name, etc.) are optional and only appear if your provider includes the corresponding claims in the token. [UserIdentity; Auth in functions]

So yes: if you want identity.email, your token must contain an email claim.

What you need to do depends on your auth setup:

  • Convex Auth + OAuth providers: by default Convex Auth stores name, email, and image from the OAuth profile. You can customize this via the provider’s profile function and add more fields, but email is already included by default. [OAuth profile mapping]
  • Custom JWT / WorkOS / Clerk / other providers: you must configure your provider’s JWT template / claims to include email. Convex will then expose it as identity.email. [Auth in functions]

If you’re using a custom JWT and put email under a nested or custom key (e.g. properties.email), Convex will expose it under that exact key, accessible like:

const identity = await ctx.auth.getUserIdentity();
const customEmail = identity["properties.email"] as string | undefined;

for non‑standard claim names. [Auth in functions – Custom JWT]

So to get email:

#
  1. Ensure your auth provider’s token actually has an email claim.
  2. If it doesn’t, update the provider’s JWT / profile configuration to add it.
  3. Then ctx.auth.getUserIdentity() will include email automatically.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@bright socket Kapa.ai is still learning and improving, please let me know how I did by reacting below.

bright socket
#

@modest hazel can you make me an example?

modest hazelBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Yes. Here are two concrete patterns, both based on how Convex handles identity claims.


1. Custom JWT (e.g. WorkOS) – add email to the token

With a custom JWT provider, Convex exposes whatever claims you put into the JWT. [Custom JWT docs]

a) Configure your JWT to include email

In your auth provider (e.g. WorkOS JWT template), add an email claim:

{
  "aud": "convex",
  "email": "{{ user.email }}"
}

(Exact templating syntax depends on the provider; WorkOS examples in the Discord thread show similar templates for name, email, etc. [WorkOS JWT template])

Your Convex auth.config.ts stays like:

// convex/auth.config.ts
import { AuthConfig } from "convex/server";

export default {
  providers: [
    {
      type: "customJwt",
      applicationID: "convex",
      issuer: "<https://api.workos.com",>
      jwks: `<https://api.workos.com/sso/jwks/${process.env.WORKOS_CLIENT_ID}`,>
      algorithm: "RS256",
    },
  ],
} satisfies AuthConfig;

b) Read email in a Convex function

Now ctx.auth.getUserIdentity() will include email (if present in the JWT): [Auth in functions]

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

export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;

    const { subject, email } = identity;
    return { userId: subject, email: email ?? null };
  },
});

2. Nested / custom claim name

If your JWT puts email under a nested object, e.g.:

#
{
  "properties": {
    "email": "[email protected]"
  },
  "iss": "<https://api.workos.com",>
  "sub": "user_123"
}

Convex exposes it under a dot path like identity["properties.email"]. [Custom claims example]

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

export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;

    const email = identity["properties.email"] as string | undefined;
    return {
      userId: identity.subject,
      email: email ?? null,
    };
  },
});

So the key steps are:

  1. Ensure your provider’s JWT actually includes email (either top-level or nested).
  2. Read it from ctx.auth.getUserIdentity() using the matching field name or dot path.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: