fix: webhook apikey (#47317)

## TL;DR
Database webhooks/Cron jobs now add `apikey: <secret-key>` for edge
function auth..

## ref:
- related to: https://github.com/supabase/supabase/pull/46890
- towards COM-269

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## New Features
* Improved edge function webhook authentication by automatically
selecting the appropriate API key or authorization header format.
* Authorization headers are now added or normalized when required, while
preserving existing custom headers and supported credentials.

## Improvements
* Simplified “Add header” and “Add parameter” controls with clearer
labels.
* Updated authentication actions to clearly describe the selected header
type.

## Tests
* Expanded coverage for key formats, authorization behavior, header
preservation, and revised control labels.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Tomás Pozo <tomaspozo@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-08-24 10:06:36 -06:00
1 parent 8dcebc08ca
commit 1cffe632e3
8 files changed
+232 -65

No files matched your search

@@ -27,12 +27,13 @@ import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
import { HTTPHeaders } from './HTTPHeaders'
import { HTTPParameters } from './HTTPParameters'
import { HTTPRequestConfig } from './HTTPRequestConfig'
import { ensureEdgeFunctionAuthorizationHeader } from '@/components/interfaces/Functions/httpHeaderAddActions'
import {
FormSection,
FormSectionContent,
FormSectionLabel,
} from '@/components/ui/Forms/FormSection'
import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
import { useTableNamesQuery } from '@/data/tables/table-names-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
@@ -60,7 +61,8 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
projectRef: ref,
})
const legacyServiceRole = keys.find((x) => x.name === 'service_role')?.api_key ?? '[YOUR API KEY]'
const { serviceKey } = getKeys(keys)
const serviceRoleKey = serviceKey?.api_key
const httpUrl = useWatch({ control: form.control, name: 'http_url' })
const httpHeaders = useWatch({ control: form.control, name: 'httpHeaders' })
@@ -79,34 +81,18 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
if (httpUrl && isEdgeFunctionSelected) {
const fnSlug = httpUrl.split('/').at(-1)
const fn = functions.find((x) => x.slug === fnSlug)
const authorizationHeader = httpHeaders.find((x) => x.name === 'Authorization')
const edgeFunctionAuthHeaderVal = `Bearer ${legacyServiceRole}`
const updatedHttpHeaders = ensureEdgeFunctionAuthorizationHeader({
headers: httpHeaders,
serviceRoleKey,
verifyJwt: fn?.verify_jwt,
createRow: (name, value) => ({ id: uuidv4(), name, value }),
})
if (fn?.verify_jwt && authorizationHeader == null) {
const newAuthHeader = {
id: uuidv4(),
name: 'Authorization',
value: edgeFunctionAuthHeaderVal,
}
form.setValue('httpHeaders', [...httpHeaders, newAuthHeader])
} else if (fn?.verify_jwt && authorizationHeader?.value !== edgeFunctionAuthHeaderVal) {
const updatedHttpHeaders = httpHeaders.map((x) => {
if (x.name === 'Authorization') return { ...x, value: edgeFunctionAuthHeaderVal }
else return x
})
if (updatedHttpHeaders !== httpHeaders) {
form.setValue('httpHeaders', updatedHttpHeaders)
}
}
}, [
form,
functions,
httpHeaders,
httpUrl,
isSuccessEdgeFunctions,
legacyServiceRole,
ref,
restUrl,
])
}, [form, functions, httpHeaders, httpUrl, isSuccessEdgeFunctions, serviceRoleKey, ref, restUrl])
return (
<div>
@@ -38,7 +38,6 @@ export const HTTPHeaders = ({ form }: HTTPHeadersProps) => {
functionType === 'supabase_function'
? buildEdgeFunctionHeaderAddActions({
apiKey,
includeApiKeyHeader: serviceKey?.type === 'secret',
createRow: (name: string, value: string) => ({ id: uuidv4(), name, value }),
})
: []
@@ -56,7 +55,7 @@ export const HTTPHeaders = ({ form }: HTTPHeadersProps) => {
createEmptyRow={() => ({ id: uuidv4(), name: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add a new header"
addLabel="Add header"
addActions={addActions}
/>
</FormSectionContent>
@@ -35,7 +35,7 @@ describe('HTTPParameters', () => {
render(<HTTPParametersHarness />)
await user.click(screen.getByRole('button', { name: 'Add a new parameter' }))
await user.click(screen.getByRole('button', { name: 'Add parameter' }))
expect(screen.getAllByPlaceholderText('Parameter name')).toHaveLength(2)
expect(screen.getAllByPlaceholderText('Parameter value')).toHaveLength(2)
@@ -27,7 +27,7 @@ export const HTTPParameters = ({ form }: HTTPParametersProps) => {
createEmptyRow={() => ({ id: uuidv4(), name: '', value: '' })}
keyPlaceholder="Parameter name"
valuePlaceholder="Parameter value"
addLabel="Add a new parameter"
addLabel="Add parameter"
removeLabel="Remove parameter"
/>
</FormSectionContent>
@@ -1,30 +1,159 @@
import { describe, expect, it } from 'vitest'
import { buildEdgeFunctionHeaderAddActions } from './httpHeaderAddActions'
import {
buildEdgeFunctionHeaderAddActions,
ensureEdgeFunctionAuthorizationHeader,
getEdgeFunctionAuthHeader,
} from './httpHeaderAddActions'
describe('buildEdgeFunctionHeaderAddActions', () => {
it('includes the apikey header when requested', () => {
it('includes the apikey header for secret keys', () => {
const [authAction] = buildEdgeFunctionHeaderAddActions({
apiKey: 'secret-key',
includeApiKeyHeader: true,
apiKey: 'sb_secret_123',
createRow: (name, value) => ({ name, value }),
})
expect(authAction.createRows()).toEqual([
{ name: 'Authorization', value: 'Bearer secret-key' },
{ name: 'apikey', value: 'secret-key' },
])
expect(authAction.label).toBe('Add secret key')
expect(authAction.description).toBe(
'Requires JWT verification to be disabled and authorization handled by the function'
)
expect(authAction.createRows()).toEqual([{ name: 'apikey', value: 'sb_secret_123' }])
})
it('omits the apikey header when not requested', () => {
it('falls back to the Authorization header for legacy keys', () => {
const [authAction] = buildEdgeFunctionHeaderAddActions({
apiKey: 'service-key',
includeApiKeyHeader: false,
apiKey: 'legacy-service-role-jwt',
createRow: (name, value) => ({ name, value }),
})
expect(authAction.label).toBe('Add secret key')
expect(authAction.description).toBe('Required for edge functions that enforce JWT verification')
expect(authAction.createRows()).toEqual([
{ name: 'Authorization', value: 'Bearer service-key' },
{ name: 'Authorization', value: 'Bearer legacy-service-role-jwt' },
])
})
})
describe('getEdgeFunctionAuthHeader', () => {
it('returns the apikey header for publishable keys', () => {
expect(getEdgeFunctionAuthHeader('sb_publishable_123')).toEqual({
name: 'apikey',
value: 'sb_publishable_123',
})
})
it('returns the Authorization header for non-prefixed keys', () => {
expect(getEdgeFunctionAuthHeader('legacy-service-role-jwt')).toEqual({
name: 'Authorization',
value: 'Bearer legacy-service-role-jwt',
})
})
it.each(['sb_secretly_123', 'sb_publishableish_123'])(
'does not treat %s as a new API key',
(apiKey) => {
expect(getEdgeFunctionAuthHeader(apiKey)).toEqual({
name: 'Authorization',
value: `Bearer ${apiKey}`,
})
}
)
})
describe('ensureEdgeFunctionAuthorizationHeader', () => {
const createRow = (name: string, value: string) => ({ id: 'new', name, value })
it('preserves apikey while normalizing Authorization rows', () => {
const headers = [
{ id: 'custom-before', name: 'X-Before', value: 'before' },
{ id: 'authorization', name: ' authorization ', value: 'Bearer old-key' },
{ id: 'apikey', name: 'apikey', value: 'sb_secret_123' },
{ id: 'duplicate', name: 'AUTHORIZATION', value: 'Bearer stale-key' },
{ id: 'custom-after', name: 'X-After', value: 'after' },
]
expect(
ensureEdgeFunctionAuthorizationHeader({
headers,
serviceRoleKey: 'legacy-service-role-jwt',
verifyJwt: true,
createRow,
})
).toEqual([
{ id: 'custom-before', name: 'X-Before', value: 'before' },
{
id: 'authorization',
name: 'Authorization',
value: 'Bearer old-key',
},
{ id: 'apikey', name: 'apikey', value: 'sb_secret_123' },
{ id: 'custom-after', name: 'X-After', value: 'after' },
])
})
it('adds Authorization alongside apikey when JWT verification is enabled', () => {
const headers = [{ id: 'apikey', name: 'apikey', value: 'sb_secret_123' }]
expect(
ensureEdgeFunctionAuthorizationHeader({
headers,
serviceRoleKey: 'legacy-service-role-jwt',
verifyJwt: true,
createRow,
})
).toEqual([
{ id: 'apikey', name: 'apikey', value: 'sb_secret_123' },
{ id: 'new', name: 'Authorization', value: 'Bearer legacy-service-role-jwt' },
])
})
it('does not change headers without an applicable service role key', () => {
const headers = [
{ id: 'authorization', name: 'Authorization', value: 'Bearer existing-key' },
{ id: 'apikey', name: 'apikey', value: 'sb_secret_123' },
]
expect(
ensureEdgeFunctionAuthorizationHeader({
headers,
serviceRoleKey: 'legacy-service-role-jwt',
verifyJwt: false,
createRow,
})
).toBe(headers)
expect(ensureEdgeFunctionAuthorizationHeader({ headers, verifyJwt: true, createRow })).toBe(
headers
)
expect(
ensureEdgeFunctionAuthorizationHeader({
headers,
serviceRoleKey: 'sb_secret_456',
verifyJwt: true,
createRow,
})
).toBe(headers)
})
it.each(['Bearer user-entered-token', ''])(
'preserves a user-entered Authorization value: %j',
(value) => {
const headers = [
{
id: 'authorization',
name: 'Authorization',
value,
},
{ id: 'apikey', name: 'apikey', value: 'sb_secret_123' },
]
expect(
ensureEdgeFunctionAuthorizationHeader({
headers,
serviceRoleKey: 'legacy-service-role-jwt',
verifyJwt: true,
createRow,
})
).toBe(headers)
}
)
})
@@ -2,29 +2,83 @@ import type { KeyValueFieldArrayAction } from 'ui-patterns/form/KeyValueFieldArr
interface BuildEdgeFunctionHeaderAddActionsParams<TRow> {
apiKey: string
includeApiKeyHeader?: boolean
createRow: (name: string, value: string) => TRow
}
interface HTTPHeader {
name: string
value: string
}
interface EnsureEdgeFunctionAuthorizationHeaderParams<TRow extends HTTPHeader> {
headers: TRow[]
serviceRoleKey?: string
verifyJwt?: boolean
createRow: (name: string, value: string) => TRow
}
const isNewApiKey = (apiKey: string) =>
apiKey.startsWith('sb_secret_') || apiKey.startsWith('sb_publishable_')
export const getEdgeFunctionAuthHeader = (apiKey: string) =>
isNewApiKey(apiKey)
? { name: 'apikey', value: apiKey }
: { name: 'Authorization', value: `Bearer ${apiKey}` }
export const ensureEdgeFunctionAuthorizationHeader = <TRow extends HTTPHeader>({
headers,
serviceRoleKey,
verifyJwt,
createRow,
}: EnsureEdgeFunctionAuthorizationHeaderParams<TRow>): TRow[] => {
if (!verifyJwt || !serviceRoleKey || isNewApiKey(serviceRoleKey)) return headers
const isAuthorization = (header: HTTPHeader) =>
header.name.trim().toLowerCase() === 'authorization'
const authorizationIndex = headers.findIndex(isAuthorization)
if (authorizationIndex === -1) {
return [...headers, createRow('Authorization', `Bearer ${serviceRoleKey}`)]
}
const authorizationHeader = headers[authorizationIndex]
const normalizedHeaders = headers.filter(
(header, index) => index === authorizationIndex || !isAuthorization(header)
)
if (normalizedHeaders.length === headers.length && authorizationHeader.name === 'Authorization') {
return headers
}
normalizedHeaders[authorizationIndex] = {
...authorizationHeader,
name: 'Authorization',
}
return normalizedHeaders
}
export const buildEdgeFunctionHeaderAddActions = <TRow>({
apiKey,
includeApiKeyHeader = false,
createRow,
}: BuildEdgeFunctionHeaderAddActionsParams<TRow>): KeyValueFieldArrayAction<TRow>[] => [
{
key: 'add-auth-header',
label: 'Add auth header with secret key',
description: 'Required if your edge function enforces JWT verification',
createRows: () => [
createRow('Authorization', `Bearer ${apiKey}`),
...(includeApiKeyHeader ? [createRow('apikey', apiKey)] : []),
],
},
{
key: 'add-source-header',
label: 'Add custom source header',
description: 'Useful to verify that the edge function was triggered from this webhook',
createRows: () => createRow('x-supabase-webhook-source', '[Use a secret value]'),
separatorAbove: true,
},
]
}: BuildEdgeFunctionHeaderAddActionsParams<TRow>): KeyValueFieldArrayAction<TRow>[] => {
const authHeader = getEdgeFunctionAuthHeader(apiKey)
return [
{
key: 'add-auth-header',
label: 'Add secret key',
description:
authHeader.name === 'apikey'
? 'Requires JWT verification to be disabled and authorization handled by the function'
: 'Required for edge functions that enforce JWT verification',
createRows: () => [createRow(authHeader.name, authHeader.value)],
},
{
key: 'add-source-header',
label: 'Add custom source',
description: 'Useful to verify that the edge function was triggered from this webhook',
createRows: () => createRow('x-supabase-webhook-source', '[Use a secret value]'),
separatorAbove: true,
},
]
}
@@ -29,7 +29,6 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp
variant === 'edge_function'
? buildEdgeFunctionHeaderAddActions({
apiKey,
includeApiKeyHeader: serviceKey?.type === 'secret',
createRow: (name: string, value: string) => ({ name, value }),
})
: []
@@ -45,7 +44,7 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp
createEmptyRow={() => ({ name: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add a new header"
addLabel="Add header"
addActions={addActions}
/>
</SheetSection>
@@ -56,7 +56,7 @@ const openWebhookEditor = async (page: Page, hookName: string) => {
}
const addCustomHeader = async (page: Page, name: string, value: string) => {
await page.getByRole('button', { name: 'Add a new header' }).click()
await page.getByRole('button', { name: 'Add header' }).click()
await page.getByPlaceholder('Header name').last().fill(name)
await page.getByPlaceholder('Header value').last().fill(value)
}