Supabase "Hook requires authorization token": the fix
"Hook requires authorization token" means your HTTP auth hook answered with a 401. Supabase Auth never sends an Authorization header to hooks; it signs each request with Standard Webhooks headers. If the hook is an Edge Function, set verify_jwt = false for that one function, then verify the signature in code instead of deleting the check.
Why does Supabase say "Hook requires authorization token"?
Because your hook endpoint returned HTTP 401, and Supabase Auth turns that status into this message no matter what your response body says. The mapping lives in internal/hooks/hookshttp/hookshttp.go in supabase/auth:
case http.StatusUnauthorized:
return nil, apierrors.NewInternalServerError(
"Hook requires authorization token")
Every other status is mapped in the same switch. A 204 passes and a 200 or 202 has its JSON body read. A 400 becomes Invalid payload sent to hook, a 429 or 503 with a retry-after header is retried, and anything else, 403 included, becomes Unexpected status code returned from hook: <code>. Each of these errors reaches the app calling Auth as a 500.
Despite the wording, Auth never sends a token. The same file sets five request headers: Content-Type, webhook-id, webhook-timestamp, webhook-signature and Accept-Encoding. There's no Authorization header, so anything that insists on one rejects every call. The error isn't specific to email: discussion #38579, opened in September 2025 and still unanswered, started with a Before User Created hook.
Three things return that 401
1. The platform's JWT check. If the hook is a Supabase Edge Function with verify_jwt on, the gateway rejects the request before your code runs, because there's no JWT to check. In #38579 one developer's logs showed a 401 and a function that was never invoked. Another found newly created functions had "Verify JWT with legacy secret" switched on. A third said the switch turned itself back on every time they deployed.
2. A check for a header Auth never sends. supabase/auth#2499, open since April 2026 with no replies, is a Send Email hook on Vercel that required Authorization: Bearer. Its server log shows authHeader present: false, len: 0, and GoTrue's own log shows 500: Hook requires authorization token. align-the-humans PR #12, merged on 23 October 2026, fixed a handler that demanded x-supabase-webhook-signature, a header Supabase doesn't send, and returned 401 "Missing webhook signature". The production log had expired, so its author tied that 401 to Supabase's message by inference.
3. A catch-all 401. Supabase's own Send Email hook docs show a Resend example that wraps wh.verify(...) and resend.emails.send(...) in one try, and its catch returns status: 401. So a Resend failure, or a signature failure from a mismatched or rotated secret, shows up as this message. In #38579, a developer traced theirs to the sample's placeholder sender, onboarding@example.com, which they hadn't replaced.
All three point at the same tempting fix: delete the check.
Triage: does the function log anything?
If your function logs nothing for the failing request, it's cause 1. The gateway answered for it, and the execution_id check in our 401 Invalid JWT post confirms that from the edge logs. If it does log, it's cause 2 or 3. Log the reason before each return that sends a 401, and the next failure will tell you which one.
The fix that opens the hook
Here's where the deleted check leads. The function clears the error and sends mail again:
import { Resend } from "npm:resend";
const resend = new Resend(Deno.env.get("RESEND_API_KEY")!);
// Signature check removed: it kept failing with
// "Hook requires authorization token". Emails send again.
Deno.serve(async (req) => {
const { user, email_data } = await req.json();
const link =
`${email_data.site_url}/auth/confirm?token_hash=${email_data.token_hash}` +
`&type=${email_data.email_action_type}`;
await resend.emails.send({
from: "Acme <auth@acme.dev>",
to: [user.email],
subject: "Confirm your email",
html: `<a href="${link}">Confirm your email</a>`,
});
return Response.json({});
});
- Warningsupabase/functions/send-email/index.ts:7
Edge function without auth validation
Verify the caller inside the function. For user calls, wrap the handler in withSupabase({ auth: 'user' }, handler) from @supabase/server, or read the Authorization header, call supabase.auth.getUser(token) or auth.getClaims(token), and reject when there is no user. For cron or internal calls, use withSupabase({ auth: 'secret' }) (or 'secret:<name>') or compare a dedicated secret header to a server-side secret, and fail closed when that secret is unset. For provider webhooks, verify the provider's signature. The 'none' and 'publishable' modes do not authenticate the caller: a publishable key ships in every client. verify_jwt = true alone is not enough, because the public anon key is a valid JWT.
The function lives at https://<project-ref>.supabase.co/functions/v1/send-email. Your project ref ships in every client bundle, and the payload format is in the docs. Anyone can POST a body with a recipient, a token_hash and a site_url of their choosing. Your verified domain then sends a "Confirm your email" message whose link points wherever they like. The link copies the {{ .SiteURL }}/auth/confirm?token_hash=... template from Supabase's password docs, so the forgery looks like the real thing. Auth's email rate limits never see these sends, because the request never passes through Auth. A Send SMS hook left open the same way sends texts on your bill.
The workaround in #2499, to "accept requests without auth header and log a warning", is the same hole with a log line.
How do I fix "Hook requires authorization token" safely?
Turn off the gateway's JWT check for that one Edge Function, verify the Standard Webhooks signature in code, and return 401 only when that verification fails. Supabase's auth hooks guide says the same about the JWT check:
Disable JWT verification via the
--no-verify-jwtto accommodate hooks which are run before a JWT is issued. Payload authenticity is instead protected via the appended security headers associated with the Standard Webhooks Standard.
Put the setting in supabase/config.toml, so every deploy carries it, rather than relying on the dashboard toggle:
[functions.send-email]
verify_jwt = false
Then split the try so that the 401 can only mean a bad signature:
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
import { Resend } from "npm:resend";
const resend = new Resend(Deno.env.get("RESEND_API_KEY")!);
const wh = new Webhook(Deno.env.get("SEND_EMAIL_HOOK_SECRET")!.replace("v1,whsec_", ""));
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
Deno.serve(async (req) => {
const payload = await req.text();
// 1. Signature only. This is the one place that may return 401.
let event;
try {
event = wh.verify(payload, Object.fromEntries(req.headers));
} catch {
return json({ error: "invalid signature" }, 401);
}
const { user, email_data } = event as {
user: { email: string };
email_data: { site_url: string; token_hash: string; email_action_type: string };
};
// Same email as before, now built only from a payload Auth signed.
const link =
`${email_data.site_url}/auth/confirm?token_hash=${email_data.token_hash}` +
`&type=${email_data.email_action_type}`;
// 2. The provider. A failure here is a 200 carrying an error object,
// so Supabase Auth reads it and passes your message on.
const { error } = await resend.emails.send({
from: "Acme <auth@acme.dev>",
to: [user.email],
subject: "Confirm your email",
html: `<a href="${link}">Confirm your email</a>`,
});
if (error) {
console.error("resend failed", error);
return json({ error: { http_code: 500, message: "Could not send the email. Try again." } });
}
return json({});
});
The provider branch returns 200 because that's the only way your message survives. Auth reads the body only on a 200 or 202, and only with an application/json Content-Type. hookserrors.Check in supabase/auth then turns { "error": { "http_code", "message" } } into the response your app gets. A missing http_code becomes 500, and an error object with an empty message counts as no error at all. On any other status the body is never read. The docs list "The email or phone provider used in the webhook returned an error" as a case for returning an error.
The v1,whsec_ secret format, the prefix strip and the five-minute timestamp window are covered in where the Supabase Auth Hook secret comes from, which also explains why Database Webhooks, unlike auth hooks, aren't signed at all. A hook on a Next.js route has no gateway check, so only causes 2 and 3 apply. Verify with the standardwebhooks npm package against await request.text().
What GuardLayer catches, and what it doesn't
- Edge Functions. An unverified hook in
supabase/functions/<name>/index.tsfiressupabase/edge-function-no-auth, the warning above. So does a presence-only check ofx-supabase-webhook-signature, and so does a warn-and-continue handler. The finding names theAuthorizationheader because that's how user-facing functions authenticate, and Edge Functions are public by default. For a hook, the fix is the signature check:new Webhook(...)plus.verify(...)clears the finding. Adding anAuthorizationcheck would just bring this error back. - Next.js routes. A receiver whose path contains
webhook, likeapp/api/webhooks/send-email/route.ts, firesnextjs/webhook-no-signature-verificationwhen it's unverified, presence-only or warn-and-continue. - Not caught: the same receiver at
app/api/auth/hooks/send-email/route.tsgets no finding, because the rule keys onwebhookin the path and doesn't treat reading thewebhook-signatureheader as a marker. A Bearer check against the hook secret also gets no finding. It's a real gate that Auth can't pass, so the hook breaks but nothing is left open. The docs' catch-all example scans clean too: its 401 is a reliability bug, not a hole.
GuardLayer reads your repo, so it can't see the dashboard toggle, and edge-function-no-auth judges every handler as if verify_jwt were off.
Quick self-check
# Hooks that still demand a header Auth never sends
grep -rniE "authorization|x-supabase-webhook-signature" supabase/functions/send-email supabase/functions/send-sms
# Per-function JWT setting
grep -n -A3 "functions.send-" supabase/config.toml
Then POST an unsigned body to your deployed hook, addressed to yourself:
curl -i -X POST https://<project-ref>.supabase.co/functions/v1/send-email \
-H "Content-Type: application/json" \
-d '{"user":{"email":"you@yourdomain.com"},"email_data":{"site_url":"https://example.com","token_hash":"test","email_action_type":"signup"}}'
A 401 with your invalid signature body is the right answer. A 200, or an email arriving, means the hook trusts the body.
FAQ
Does Supabase send an Authorization header to auth hooks?
No. It sends webhook-id, webhook-timestamp and webhook-signature, signed with your hook's v1,whsec_ secret. Verify those with a Standard Webhooks library.
Is it safe to turn off Verify JWT for a Send Email hook?
Yes, if the function verifies the Standard Webhooks signature and rejects anything that fails. Turn it off for that function only, in supabase/config.toml.
Why do I get "Hook requires authorization token" when Resend fails?
If you copied the docs' Resend example, its single catch returns 401 for both signature and provider errors. Auth maps any 401 to this message. Split the try and return provider errors as a 200 with an error object.
What should my hook return when the email provider fails?
Status 200 with Content-Type: application/json and {"error":{"http_code":500,"message":"..."}}. Auth only reads the body on 200 or 202, and it ignores an error object without a message.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.