Or
Or2mo ago

Restrict Convex mutation to external Vercel functions, and block access from the client side

Hi, Since Convex actions are very limited, many peopole prefer to use Vercel functions (espicially with their fluid runtime). I need a Convex mutation which is restricted to external Vercel functions, but block access from the client side. Currently on other Discord post I saw that you advised to send a shared secret as a parameter, but it's not considered secure since it can be accidently printed in logs... and it's a bad practice to send secret as a parameter. I thought on something else: In Vercel function - tell Clerk (using Clerk api-key) (my auth provider) to issue a JWT with custom claim (e.g. role=backend) and timeout of 60 seconds, and then call the Convex mutation, and in the mutation check if the JWT contains the custom claim and allow/block access accordingly. There are some docs in Clerk about this: https://clerk.com/docs/backend-requests/custom-session-token https://clerk.com/docs/backend-requests/jwt-templates Can you please write an example for how to do it in such secure way? I'm struggling with this, and I think many folks who uses next.js with Vercel functions will benefit from this example. Thanks
Backend Requests: Customize your session token
Learn how to customize the session token that is generated for you by Clerk.
Backend Requests: JWT templates
Learn how to create custom JWT templates to generate JSON Web Tokens with Clerk.
6 Replies
Convex Bot
Convex Bot2mo ago
Thanks for posting in <#1088161997662724167>. Reminder: If you have a Convex Pro account, use the Convex Dashboard to file support tickets. - Provide context: What are you trying to achieve, what is the end-user interaction, what are you seeing? (full error message, command output, etc.) - Use search.convex.dev to search Docs, Stack, and Discord all at once. - Additionally, you can post your questions in the Convex Community's <#1228095053885476985> channel to receive a response from AI. - Avoid tagging staff unless specifically instructed. Thank you!
erquhart
erquhart2mo ago
I haven't tried this, but you should be able to set up multiple clerk jwt templates by adding more entries to the convex/auth.config.ts array, you'd just need to set a different audience for each, maybe this one can be clerk-backend-only. And I believe the custom claim will then be present on the identity object from ctx.auth.getUserIdentity. Specify the correct jwt template when you get the token from clerk, also Hopefully that works, curious to hear if it does
Or
OrOP2mo ago
I tried, but didn't manage to do it easily, and since I just wanted to test Convex as POC to see if it fit for us..., I gave up on this... If the Convex team can write example for this it would be great!
Aboud
Aboud3w ago
You can make an internalMutation, and then wrap it in an httpAction, and then call that from your vercel function as follows:
const response = await fetch(`${env.CONVEX_SITE_URL}/myFunction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-my-convex-secret': env.MY_CONVEX_SECRET!,
},
body: JSON.stringify(payload),
});
const response = await fetch(`${env.CONVEX_SITE_URL}/myFunction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-my-convex-secret': env.MY_CONVEX_SECRET!,
},
body: JSON.stringify(payload),
});
One issue with this is that a simple mutation ends up being 2 function calls for billing purposes (the http action and the internal mutation). Ideally, there would be a way to mutate the database in actions or httpActions, but i understand this is not possible (and maybe not desirable).
erquhart
erquhart3w ago
They're specifically looking for an alternative to a shared secret
Currently on other Discord post I saw that you advised to send a shared secret as a parameter, but it's not considered secure since it can be accidently printed in logs... and it's a bad practice to send secret as a parameter.
Aboud
Aboud3w ago
Understood. We also did this using the
jose
jose
package to mint a custom token with limited expiry and encrypted payload...
export const publicDeclineRequest = mutation({
args: {
token: v.string(),
},
handler: async (ctx, args) => {
const { token } = args

const payload = await decryptToken(token, process.env.MY_CONVEX_SECRET!)
if (!payload) throw new ConvexError('Invalid token')

const { request_id } = payload as { request_id: Id<'requests'> }
export const publicDeclineRequest = mutation({
args: {
token: v.string(),
},
handler: async (ctx, args) => {
const { token } = args

const payload = await decryptToken(token, process.env.MY_CONVEX_SECRET!)
if (!payload) throw new ConvexError('Invalid token')

const { request_id } = payload as { request_id: Id<'requests'> }
which we can invoke in a vercel API route / server action using the fetchMutation. The pairs are:
import { EncryptJWT, jwtDecrypt, type JWTPayload } from 'jose'

export type { JWTPayload }

type Options = {
expiresIn?: string
}

export const createToken = async (payload: any, secret: string, options?: Options) => {
const { expiresIn = '2y' } = options || {}

if (secret.length < 32) {
throw new Error('Secret must be at least 32 characters long')
}

try {
const secretKey = new TextEncoder().encode(secret)

const token = await new EncryptJWT(payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.encrypt(secretKey)

return token
} catch (error) {
console.error('Error creating token', error)
throw error
}
}

export const decryptToken = async (token: string, secret: string) => {
if (!token) {
throw new Error('Token is required')
}

if (!secret || secret.length < 32) {
throw new Error('Valid secret is required')
}

try {
const secretKey = new TextEncoder().encode(secret)

const { payload } = await jwtDecrypt(token, secretKey)

return payload
} catch (err) {
console.error('Error decrypting token', err)
throw err
}
}
import { EncryptJWT, jwtDecrypt, type JWTPayload } from 'jose'

export type { JWTPayload }

type Options = {
expiresIn?: string
}

export const createToken = async (payload: any, secret: string, options?: Options) => {
const { expiresIn = '2y' } = options || {}

if (secret.length < 32) {
throw new Error('Secret must be at least 32 characters long')
}

try {
const secretKey = new TextEncoder().encode(secret)

const token = await new EncryptJWT(payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.encrypt(secretKey)

return token
} catch (error) {
console.error('Error creating token', error)
throw error
}
}

export const decryptToken = async (token: string, secret: string) => {
if (!token) {
throw new Error('Token is required')
}

if (!secret || secret.length < 32) {
throw new Error('Valid secret is required')
}

try {
const secretKey = new TextEncoder().encode(secret)

const { payload } = await jwtDecrypt(token, secretKey)

return payload
} catch (err) {
console.error('Error decrypting token', err)
throw err
}
}
i imagine if one wanted to not have token be in the args (because it might appear in logs), one can convert this to an httpAction and put the token in the header (although that might also appear in logs).

Did you find this page helpful?