Skip to main content
interlace
SecurityPlugin: supabase-securityRules

no-dynamic-rpc-name

Call `.rpc()` with a literal function name

Call .rpc() with a literal function name.

Why

supabase.rpc(name, args) executes a Postgres function by name over PostgREST. When the name is computed, the caller chooses which function runs — and a Supabase project's exposed schema routinely holds privileged helpers next to the intended one: delete_user, grant_admin, reset_password.

This is the same shape as a dynamic require(): the danger is not the arguments, it is that the callee is data.

Rule details

Fires only in files importing a @supabase/* package. Reports .rpc(x) where x is not one of a set of names this file fixes.

Fixed, and not reported — followed within the file, bounded:

  • a string literal, and a template literal whose every interpolation is itself fixed or a number / boolean literal (`get_profile`, `stats_v${V}` with const V = 2);
  • a const holding one, and a ternary between fixed names;
  • a lookup in a same-file object of literal names (RPC[kind], RPC.single), including an as const table;
  • a parameter whose declared type is a union of string literals (name: 'get_a' | 'get_b'), directly or through a same-file type alias — whatever a caller passes, the compiler holds it to that list.

A string parameter, a request field, a reassigned let, a table with a computed or spread entry, and anything imported are not fixed, and report. A spread argument is unreadable from the call site and is left alone.

Incorrect

import { createClient } from '@supabase/supabase-js';

await db.rpc(req.body.fn, args);
await db.rpc(`get_${kind}`, args);

Correct

import { createClient } from '@supabase/supabase-js';

await db.rpc('get_user_profile', { id });

// Branch to a literal call rather than computing the name.
if (kind === 'profile') return db.rpc('get_user_profile', { id });
return db.rpc('get_user_orders', { id });

Choosing between functions safely

Pick from a closed set the file can see, and the rule stays silent:

const FN = { profile: 'get_user_profile', orders: 'get_user_orders' } as const;

export async function load(kind: keyof typeof FN, id: string) {
  return db.rpc(FN[kind], { id });
}

A generic wrapper that takes the name as a string is still reported: nothing at the call constrains what a caller passes. Narrow the parameter's type to a union of the function names, or move the choice into the wrapper.

Further reading

Use this rule

npm install -D oxlint-plugin-supabase-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-supabase-security"],  "rules": {    "supabase-security/no-dynamic-rpc-name": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-supabase-security'],    rules: {      'supabase-security/no-dynamic-rpc-name': 'error',    },  },});

oxlint-plugin-supabase-security re-exports eslint-plugin-supabase-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.