Build API Middleware with Supabase
Add reusable auth, CORS, and logging middleware to a deployed Fetch API.
Introduction
30 Second Summary
A bare API can answer requests without explaining who called it or whether the caller has access. Those gaps become harder to manage each time the same checks are copied into another handler.
In this project, you will wrap a bare fetch handler in reusable Supabase Middleware. The finished stack covers request logging plus CORS handling plus API key authentication.
What You'll Build
A request to your public URL returns the same middleware-protected JSON response you tested locally.
By the end of this project, you'll have:
- An observable local API that returns JSON while showing one terminal log for each request.
- A CORS-aware handler whose response headers are visible in your browser's network tools.
- An API key-protected public endpoint that rejects missing credentials while accepting a valid key through the same typed middleware stack.
- Secret Mission: An optional challenge to push your middleware skills further.
Do I need anything before starting?
You need Node.js or Deno plus a terminal. Basic TypeScript familiarity helps you follow the typed context step.
Before We Start
Before you build the first Edge Function, you need a local workspace that can run the project tools. You also need a hosted destination for the final deployment.
A Supabase project provides that hosted destination. You will create one before connecting your terminal to the platform.
Node.js runs the project-local Supabase CLI. Docker provides the local services that later steps depend on.
In this step, get ready to:
- Create a Supabase account with a hosted project.
- Prepare the local runtimes required by the project.
- Generate the Edge Function scaffold inside a local repository.
Create your Supabase project
Your hosted project gives the API a public destination when you deploy it later. Creating it now also gives the CLI an account to authenticate against.
- Visit the Supabase sign-up page.
- Complete the account registration with GitHub or email.
- Visit database.new to start a hosted project.
- Enter middleware-api as the project name.
The database password protects your hosted database. You do not paste it into project code during this step.
- Create a strong database password.
- Store the database password in your password manager.
- Choose the region closest to you.
A new project can spend a short while provisioning. That pause means Supabase is preparing the database behind your project.
- Submit the project form.
- Wait for the project dashboard to finish provisioning.
- Confirm the dashboard shows middleware-api as the active project.
Your hosted project is ready. It now provides the deployment target for the API you build in later steps.
Prepare Node.js and Docker
The npm installation method keeps the Supabase CLI inside this project. It requires Node.js 20 or later.
- Check your Node.js version by running this command:
node --version
What does this command check?
This command prints the Node.js version available in your terminal. The major version is the first number in the output.
✔️ I see version 20 or higher
Node.js is ready to run the project-local CLI. Keep this terminal available for the repository setup.
ⓧ I see an older version
Your current Node.js version cannot run the npm-based Supabase CLI. Install a supported release before continuing.
- Visit the official Node.js download page.
- Download an LTS installer for version 20 or higher.
- Run the downloaded installer.
- Return to your terminal after the installation finishes.
- Repeat the Node.js version check above.
ⓧ Command not found
Node.js is not available in your terminal. The official installer includes npm as well.
- Visit the official Node.js download page.
- Download an LTS installer for version 20 or higher.
- Run the downloaded installer.
- Return to your terminal after the installation finishes.
- Repeat the Node.js version check above.
Still cannot check Node.js?
Close your current terminal after installing Node.js. Start a fresh terminal so it can load the updated command path.
If the version is still missing or too old, help me fix my Node.js installation.
Docker must be running before the Supabase CLI can start local services. A daemon check confirms that the command and its background engine are both available.
- Check whether Docker is running by using this command:
docker info
What does this command check?
This command asks the Docker engine for system information. A complete response proves the command can reach the running engine.
✔️ I see Docker information
Docker is available for the local Supabase services. Leave Docker running while you work through the project.
ⓧ Command not found
Docker Desktop needs to be installed before the terminal can use the Docker command.
- Open the official macOS installation guide or the official Windows installation guide.
- Download the installer for your operating system.
- Run the downloaded installer.
- Accept the service agreement if prompted.
- Press Cmd+Space on macOS or the Windows key on Windows.
- Type Docker into the search bar.
- Press Enter to start Docker Desktop.
- Repeat the Docker check above after Docker Desktop finishes starting.
ⓧ The engine is unavailable
The Docker command is installed. Its background engine still needs to start.
- Press Cmd+Space on macOS or the Windows key on Windows.
- Type Docker into the search bar.
- Press Enter to start Docker Desktop.
- Wait for Docker Desktop to report that the engine is running.
- Repeat the Docker check above.
Docker still unavailable?
Restart Docker Desktop if the engine stays unavailable. A computer restart can also complete operating system changes made by the installer.
If the check still fails, help me diagnose Docker Desktop.
Generate the Edge Function scaffold
The CLI is installed as a development dependency inside the repository. This keeps its files tied to the project instead of adding a global command to your machine.
- Press Cmd+Space on macOS or the Windows key on Windows.
- Type Terminal on macOS or PowerShell on Windows.
- Press Enter to start the terminal.
- Create the repository on your Desktop by running these commands:
cd ~/Desktop
mkdir supabase-middleware-api
cd supabase-middleware-api
npm init -y
What do these commands create?
- The first command moves the terminal to your Desktop.
- The next commands create the supabase-middleware-api folder. They also move the terminal into it.
- The final command creates package.json with default project metadata.
- Confirm the terminal output names a package.json file inside supabase-middleware-api.
- Install the Supabase CLI as a development dependency by running:
npm install supabase --save-dev
Why install the CLI here?
The command records the Supabase CLI in the repository's development dependencies. You can run that local copy through npm's package runner.
- Verify the local CLI installation by running:
npx supabase --help
What does this command prove?
The package runner finds the CLI inside this repository. The help output confirms that the command can start successfully.
You should see Supabase CLI usage information with a list of available commands.
CLI installation failed?
Confirm your terminal is inside the supabase-middleware-api folder. Also confirm the earlier Node.js check shows version 20 or higher.
If npm still cannot install the package, help me fix the Supabase CLI installation.
Authentication connects the local CLI to the Supabase account you created earlier. The command opens a browser so you can approve access without pasting a credential into the terminal.
- Start the browser authentication flow by running:
npx supabase login
What happens during login?
The CLI opens a browser authentication flow. After you approve it, the CLI stores an access token for later platform commands.
- Complete the sign-in flow in the browser.
- Return to the terminal after the browser confirms authentication.
Before you run the check, do you expect the CLI to find the project you created earlier?
- Verify the authenticated account by listing its projects:
npx supabase projects list
What does this check confirm?
This command lists every Supabase project available to the signed-in account. Seeing your project proves the CLI authentication works.
You should see middleware-api in the project list. Your terminal is now authenticated against the correct account.
- Initialize the local Supabase directory by running:
npx supabase init
What does initialization add?
The command creates the supabase directory. It also adds supabase/config.toml for local project configuration.
- Generate the function named api by running:
npx supabase functions new api
What does the function command create?
The CLI creates a new function directory under supabase/functions. The generated TypeScript entry point is supabase/functions/api/index.ts.
Before the final check, what file do you expect the api directory to contain?
- Confirm the generated function file by running:
ls supabase/functions/api
What should this show?
The command lists the files inside the generated function directory. The scaffold is complete when the output includes index.ts.
That is the setup complete. Your repository now contains the generated Edge Function entry point that the next step turns into a working API.
Cannot find index.ts?
Confirm your terminal is still inside supabase-middleware-api. Check that the initialization command completed before you created the function.
If the folder is still missing, help me find the generated Edge Function scaffold.
✔️ Awesome, I've got everything!
Your Supabase project, local CLI, Docker runtime, and generated function scaffold are ready.
ⓧ I'd like to double check the full code
There is no hand-written application code to compare yet. Double-check the generated project state below.
- Local repository: Repository initialized with the Supabase Edge Function directory structure.
- Supabase project: An existing Supabase project is available for Edge Function deployment.
- supabase/functions/api/index.ts: Generated Supabase Edge Function scaffold for a function named api.
Your local and hosted foundations are ready. Next, you will replace the generated scaffold with a plain fetch handler that returns JSON.
Build a Plain Fetch Handler
Middleware can only prove its value when the underlying handler already works. A stable success response gives every later layer something visible to preserve or change.
A Supabase Edge Function can expose a handler built on the Fetch API. Your baseline returns a JSON response with status 200.
In this step, get ready to:
- Replace the generated scaffold with a plain Fetch handler.
- Serve the function with your local Supabase services.
- Confirm the JSON response in your browser.
Replace the generated scaffold
The generated file contains starter behaviour that your middleware stack does not need. A small handler creates a clear baseline for every response change that follows.
- Open supabase/functions/api/index.ts from the repository in your code editor.
- Select the entire generated scaffold.
- Replace the scaffold with this handler:
export default {
fetch: (_request: Request): Response => {
// Return a known response before middleware changes the request flow.
return Response.json(
{ message: "Hello from the API" },
{ status: 200 },
)
},
}
What Does This Handler Do?
- The default export gives the runtime an object with a fetch method.
- The _request parameter represents the incoming request.
- The underscore shows that the baseline handler does not inspect that request yet.
- Response.json() converts the message object into a JSON response.
- The status: 200 option marks the request as successful.
Request and Response are Web Fetch types. That shared contract keeps the handler portable across compatible runtimes.
- Save supabase/functions/api/index.ts.
- Confirm the file now contains one default export with a fetch method.
Seeing TypeScript Errors?
- Confirm you replaced the entire generated scaffold.
- Check that every brace in your file matches the reference code.
Help me compare my plain Fetch handler with the reference code.
✔️ Awesome, I've got everything!
Your handler file is ready. Make sure you saved it before starting the local runtime.
ⓧ I'd like to double check the full code
export default {
fetch: (_request: Request): Response => {
// Return a known response before middleware changes the request flow.
return Response.json(
{ message: "Hello from the API" },
{ status: 200 },
)
},
}
Run the function locally
The Supabase CLI runs a local stack that mirrors the function runtime. Docker supplies the services behind that stack.
Expect the first launch to take a few minutes while Docker downloads the required images.
- Start the local Supabase services by running this command:
supabase start
What Does This Command Do?
This command starts the local services defined by your repository. The running stack supplies the environment used by the function server.
That is the slow part done. Your local Supabase stack is ready to host the function.
Local Services Did Not Start?
- Confirm Docker is running on your computer.
- Close any other local Supabase project using the same ports.
Help me diagnose why my local Supabase services will not start.
The browser check needs the gateway to pass the request straight to your handler. The flag below disables gateway JWT checks for this local server process.
The server keeps this terminal occupied while it waits for requests.
- Serve the api function locally by running this command:
supabase functions serve api --no-verify-jwt
What Does This Command Do?
The function name limits the local server to api. Hot reloading applies saved changes without restarting the command.
The --no-verify-jwt flag lets the browser request reach your handler without a gateway token. This setting applies to this local server process.
- Keep this terminal running while you test the endpoint.
Great, your plain handler is now listening for browser requests.
Function Server Did Not Start?
- Confirm the local Supabase services finished starting.
- Confirm the terminal is still inside the repository containing the supabase folder.
Help me diagnose why my local api Edge Function will not start.
Verify the JSON response
Your local URL sends a browser request through the Supabase gateway to the fetch method. The response body gives you visible proof that the handler ran.
Before you open the endpoint, consider what JSON message the browser should show.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type the name of your web browser into the search bar.
- Press Enter to open the browser.
- Click the browser's address bar.
- Enter http://localhost:54321/functions/v1/api.
- Press Enter to request the local endpoint.
You should see {"message":"Hello from the API"} in the browser. That is your baseline working: the Fetch handler returned its JSON response with status 200.
Do Not See the JSON Response?
- Confirm the terminal running the function server remains open.
- Check that the browser address is exactly http://localhost:54321/functions/v1/api.
- Confirm you saved supabase/functions/api/index.ts after replacing the scaffold.
Help me fix my local Edge Function response.
Your baseline API works locally. Next, you will add logging middleware so each request leaves one line in the terminal.
Add Request Logging
Your plain API now returns JSON with an HTTP 200 status. The terminal still lacks an app-level record for each request.
Request logging gives each call a visible trail. In this step, you will add a reusable Supabase Middleware entry that records the request method plus URL before the Web Fetch handler returns the same response.
In this step, get ready to:
- Create a reusable logger middleware.
- Wrap the existing handler in a middleware pipeline.
- Verify that every request produces one terminal log line.
Create the logger middleware
A logger belongs in its own file because several handlers can reuse it. This middleware records the request before allowing the pipeline to continue.
- Switch back to the file editor you used earlier.
- Create supabase/functions/api/middleware/logger.ts using your editor's new-file control.
- Add the logger middleware by pasting this code into logger.ts:
import { defineMiddleware } from "npm:@supabase/middleware@1"
// Create a reusable entry that records each inbound request.
export const withLogger = defineMiddleware<
"logger",
void,
Record<never, never>,
true
>({
key: "logger",
run: () => async (request) => {
console.log(`${request.method} ${request.url}`)
return { logger: true }
},
})
What does this code do?
- The defineMiddleware function turns the logger into an entry that can join a pipeline.
- The run function creates fresh logging behaviour for each request.
- The console.log call writes the request method plus URL as one terminal line.
- The logger contribution tells the pipeline to continue into the next layer.
- Save supabase/functions/api/middleware/logger.ts.
- Confirm that your editor now lists logger.ts inside the middleware folder.
Does the logger file show a problem?
Check that the import starts with npm:@supabase/middleware@1. Confirm that logger.ts is inside the api/middleware folder.
If your editor still flags the middleware, help me diagnose the logger middleware.
✔️ Awesome, I've got everything!
Good progress. Your logger.ts file now contains one reusable request hook.
ⓧ I'd like to double check the full code
- Compare your complete logger.ts file with this reference:
import { defineMiddleware } from "npm:@supabase/middleware@1"
// Create a reusable entry that records each inbound request.
export const withLogger = defineMiddleware<
"logger",
void,
Record<never, never>,
true
>({
key: "logger",
run: () => async (request) => {
console.log(`${request.method} ${request.url}`)
return { logger: true }
},
})
Wrap the existing handler
A pipeline places reusable request logic around your handler. Its entries run before the handler while leaving the handler responsible for the API response.
- In supabase/functions/api/index.ts, replace the current contents with this middleware pipeline:
import { pipeline } from "npm:@supabase/middleware@1"
import { withLogger } from "./middleware/logger.ts"
// Keep the API's JSON response as the innermost handler.
const handler = (_request: Request): Response =>
Response.json(
{ ok: true },
{ status: 200 },
)
// Run logging before the request reaches the handler.
Deno.serve(pipeline([withLogger()], handler))
How does the pipeline work?
The pipeline function runs withLogger() before handler. The logger records the request while the handler remains focused on its JSON response.
The Deno.serve call receives the composed Fetch handler. This keeps the finished stack compatible with the Edge Function runtime.
- Save supabase/functions/api/index.ts.
- Wait for the terminal from earlier to finish reloading the local function.
Before you refresh the endpoint, what do you expect to remain unchanged in the browser? What new evidence do you expect in the terminal?
- Refresh the local API endpoint in the browser tab from earlier.
You'll see the JSON response with HTTP status 200 again. In the terminal, you'll see a new request line containing the method plus local URL.
Did the response or log disappear?
Confirm that withLogger is imported from ./middleware/logger.ts. Check that the pipeline call receives withLogger() before handler.
If the local function does not reload, help me fix the logging pipeline.
✔️ Awesome, I've got everything!
Your handler is wrapped. The API response still works while each request now passes through the logger.
ⓧ I'd like to double check the full code
- Compare your complete index.ts file with this reference:
import { pipeline } from "npm:@supabase/middleware@1"
import { withLogger } from "./middleware/logger.ts"
// Keep the API's JSON response as the innermost handler.
const handler = (_request: Request): Response =>
Response.json(
{ ok: true },
{ status: 200 },
)
// Run logging before the request reaches the handler.
Deno.serve(pipeline([withLogger()], handler))
Verify one log line per request
A useful logger emits exactly once for each request. Duplicate lines would make the traffic record misleading.
Before the final refresh, predict how many new request lines should appear in the terminal.
- Refresh the local API endpoint once.
You'll see exactly one new terminal line containing the browser request's method plus URL. The browser still shows the JSON response with HTTP status 200.
That's a reliable request trail. Your logger now records each call without changing what the API returns.
Your API can now show when each request arrives. Next, you'll add CORS middleware and inspect the headers it contributes.
Handle Cross-Origin Requests
Your Supabase Edge Function now returns JSON while the logger records every request. However, a page served from another origin needs explicit permission to read that response.
Cross-Origin Resource Sharing (CORS) uses response headers to grant that permission. You will add a reusable layer that also answers browser preflight requests.
In this step, get ready to:
- Build reusable CORS middleware for API responses.
- Compose the CORS layer with the existing logger.
- Verify response headers and preflight handling in your browser.
Build the CORS middleware
Browsers use an OPTIONS request to ask which origins can call an API. The CORS middleware answers that check before the JSON handler runs.
- In your editor sidebar, create cors.ts inside supabase/functions/api/middleware.
- Add the reusable preflight and response logic by pasting this code:
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-retry-count, traceparent, tracestate, baggage",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
}
type Handler = (request: Request) => Response | Promise<Response>
// Wrap a Fetch handler with preflight and response header handling.
export function withCors(next: Handler): Handler {
return async (request) => {
// Answer browser preflight checks before the API handler runs.
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders })
}
const response = await next(request)
// Clone existing headers so the JSON response keeps its content type.
const headers = new Headers(response.headers)
Object.entries(corsHeaders).forEach(([name, value]) => {
headers.set(name, value)
})
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
}
What does this middleware do?
- The corsHeaders object keeps the browser permissions in one reusable place.
- The OPTIONS branch returns a preflight response before the API handler processes the request.
- The cloned response keeps the original body and status. It also carries the CORS headers back to the browser.
- Save supabase/functions/api/middleware/cors.ts.
- Confirm your editor sidebar lists cors.ts beside logger.ts.
Good progress. Your reusable CORS layer can now handle preflight checks and decorate normal API responses.
Seeing a TypeScript error in cors.ts?
Check that every opening brace has a matching closing brace. Confirm that withCors is exported from the file.
Compare the three header names with the code above. A missing quote or comma can stop the function from reloading.
Help me fix my CORS middleware TypeScript error
✔️ Awesome, I've got everything!
Your CORS middleware file is saved. Continue to the middleware stack.
ⓧ I'd like to double check the full code
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-retry-count, traceparent, tracestate, baggage",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
}
type Handler = (request: Request) => Response | Promise<Response>
// Wrap a Fetch handler with preflight and response header handling.
export function withCors(next: Handler): Handler {
return async (request) => {
// Answer browser preflight checks before the API handler runs.
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders })
}
const response = await next(request)
// Clone existing headers so the JSON response keeps its content type.
const headers = new Headers(response.headers)
Object.entries(corsHeaders).forEach(([name, value]) => {
headers.set(name, value)
})
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
}
Compose the middleware stack
Middleware order decides which layer sees a request first. Keeping the logger outside the CORS layer records both normal requests and preflight requests.
- In supabase/functions/api/index.ts, keep the existing JSON response handler unchanged.
- Compose the logger and CORS layers by updating the file to match this code:
import { withCors } from "./middleware/cors.ts"
import { withLogger } from "./middleware/logger.ts"
// Keep the handler focused on the API response.
const handler = (_request: Request) =>
Response.json({ message: "Hello from the API" })
// Log every request before CORS handles it or the handler responds.
Deno.serve(withLogger(withCors(handler)))
How does the stack flow?
- The existing handler remains responsible for the JSON response.
- The withCors() layer handles preflight requests before they reach that handler.
- The outer withLogger() layer sees every request once. That includes OPTIONS requests.
- Save supabase/functions/api/index.ts.
- Record the api function URL shown in the serving terminal: your local api URL.
- Load your local api URL in your browser.
You will see the same JSON response as before. The serving terminal will show one new request log line.
API stopped reloading?
Check that both import paths start with ./middleware/. Confirm that each path ends with the matching .ts file name.
Make sure the final line passes handler into withCors() before passing the result into withLogger().
Help me fix my middleware composition
✔️ Awesome, I've got everything!
Your logger and CORS middleware now wrap the JSON handler.
ⓧ I'd like to double check the full code
import { withCors } from "./middleware/cors.ts"
import { withLogger } from "./middleware/logger.ts"
// Keep the handler focused on the API response.
const handler = (_request: Request) =>
Response.json({ message: "Hello from the API" })
// Log every request before CORS handles it or the handler responds.
Deno.serve(withLogger(withCors(handler)))
Verify CORS behavior
Your browser network tools expose the headers returned by the API. This gives you visible proof that the middleware changed the response.
Before you reload the page, which three CORS headers do you expect the API response to contain?
- Open your browser developer tools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Select the Network panel.
- Reload the local API page.
- Select the request for the api function.
- Find the response header list in the request details.
You will see Access-Control-Allow-Origin, Access-Control-Allow-Headers, and Access-Control-Allow-Methods in the response.
A normal page load sends a GET request. A browser can send an OPTIONS request first when it needs permission for a cross-origin call.
Before you send the preflight check, do you expect the JSON handler to produce the response?
- Select the Console panel in your browser developer tools.
- Send an OPTIONS request to the local API by running this code:
fetch("[[LOCAL_API_URL=\"your local api URL\"]]", { method: "OPTIONS" })
.then((response) => ({
status: response.status,
headers: Object.fromEntries(response.headers),
}))
.then(console.log)
What does this check prove?
The browser sends the request with the OPTIONS method. The console prints the returned status and readable response headers.
A successful preflight finishes before the JSON handler runs. The logger still sees it because the logger is the outer layer.
You will see status 204 plus the CORS headers in the printed object.
- Return to the terminal that is serving the api function.
You will see one new log entry containing OPTIONS and the local API URL.
CORS headers missing?
Confirm that withCors(handler) appears inside withLogger() in index.ts.
Reload the API page after saving both files. Select the newest request in the network list so you do not inspect a cached response.
Help me debug missing CORS headers
That is a major browser boundary handled. Your API now returns CORS headers while the logger records both normal and preflight requests.
Next, you will add an API key layer that rejects unauthorized requests without blocking CORS preflight checks.
Protect the API with a Key
Your Supabase Edge Function now returns JSON with CORS headers. Its logger records every request.
Anyone who knows the local URL can still reach the handler. A reusable API key gate built with Supabase Middleware will reject invalid requests before the handler runs.
In this step, get ready to:
- Build middleware that validates the request header against a local environment variable.
- Place the API key middleware after the CORS middleware.
- Confirm that invalid requests receive 401 while valid requests receive 200.
Build the API key middleware
Middleware can return a response before the main handler runs. This short-circuit blocks unauthorized callers without adding authentication logic to the handler.
- Create apiKey.ts inside supabase/functions/api/middleware in your code editor.
- Add the API key guard to apiKey.ts by pasting this code:
import { defineMiddleware, getEnv } from 'npm:@supabase/middleware@1'
// Reject requests that do not carry the configured API key.
export const withApiKey = defineMiddleware<
'apiKey',
void,
Record<never, never>,
string
>({
key: 'apiKey',
run: () => async (request) => {
// Read both values during the request so the middleware stays portable.
const providedKey = request.headers.get('x-api-key')
const expectedKey = getEnv('API_KEY')
// Stop the pipeline before the handler when authentication fails.
if (!expectedKey || providedKey !== expectedKey) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Contribute the verified key for downstream code.
return { apiKey: providedKey }
},
})
What does this middleware do?
- The x-api-key header carries the value supplied by the caller.
- The API_KEY environment variable holds the value trusted by the API.
- A missing configuration value causes a 401 response because the API cannot safely validate callers.
- A matching key lets the pipeline continue to the handler.
- Save supabase/functions/api/middleware/apiKey.ts.
- Confirm that apiKey.ts appears beside cors.ts in your editor.
You now have a separate authentication layer that can be reused around another Fetch handler.
Seeing a TypeScript error?
Check that the import starts with npm:@supabase/middleware@1. Confirm that the four type arguments remain inside the angle brackets.
Compare every brace in the run function with the full-file reference below.
Help me fix my API key middleware
✔️ Awesome, I've got everything!
Your API key middleware is saved. Continue to the local environment setup.
ⓧ I'd like to double check the full code
import { defineMiddleware, getEnv } from 'npm:@supabase/middleware@1'
// Reject requests that do not carry the configured API key.
export const withApiKey = defineMiddleware<
'apiKey',
void,
Record<never, never>,
string
>({
key: 'apiKey',
run: () => async (request) => {
// Read both values during the request so the middleware stays portable.
const providedKey = request.headers.get('x-api-key')
const expectedKey = getEnv('API_KEY')
// Stop the pipeline before the handler when authentication fails.
if (!expectedKey || providedKey !== expectedKey) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Contribute the verified key for downstream code.
return { apiKey: providedKey }
},
})
Load the local API key
An environment file keeps the trusted value outside the middleware source. The function reads that value when the local runtime starts.
This demo credential protects only your local API. Keep the file out of commits so the same pattern remains safe when you use a real credential.
- Create supabase/functions/.env.local in your code editor.
- Add the local API key to .env.local by pasting this line:
API_KEY=nextwork-demo-key
How does the local key load?
The name API_KEY matches the value requested by getEnv() in the middleware. The serve command will load this file into the Edge Function environment.
The value stays in local runtime configuration instead of being copied into the TypeScript file.
- Save supabase/functions/.env.local.
- Leave supabase/functions/.env.local out of every commit.
- Confirm that .env.local appears inside supabase/functions in your editor.
Your local environment file now holds the value that the middleware trusts.
- In supabase/functions/api/index.ts, locate the existing middleware imports.
- Add the API key middleware import below the logger import by pasting this line:
import { withApiKey } from './middleware/apiKey.ts'
What does this import add?
This import makes the reusable withApiKey entry available to the function's existing middleware pipeline.
- Locate the middleware entry list containing withLogger() and withCors().
- Replace that entry list with this ordered list:
[withLogger(), withCors(), withApiKey()]
Why does this order matter?
The logger runs first so every request is recorded. The CORS layer runs next so an OPTIONS preflight can finish before authentication.
Normal requests then reach withApiKey(). Only valid callers reach the JSON handler.
- Save supabase/functions/api/index.ts.
- Switch back to the terminal serving the function.
- Stop the current local server by pressing Ctrl+C.
- Restart the api function with the local environment file by running:
supabase functions serve api --env-file supabase/functions/.env.local --no-verify-jwt
What does this command do?
The --env-file option loads API_KEY from supabase/functions/.env.local into the local function runtime.
The --no-verify-jwt option lets your custom API key middleware decide whether each request reaches the handler.
- Keep this terminal running while you test the protected endpoint.
- Confirm that the terminal reports the api function is being served locally.
Function fails to restart?
Check that your terminal is still in the repository containing the supabase folder. Confirm that Docker is still running.
If the middleware reports a missing environment value during testing, confirm that the file path is exactly supabase/functions/.env.local.
Help me restart the protected Edge Function
Test both access paths
The first request deliberately omits the key. Before you run it, do you expect the handler to return its original JSON?
- Open a second terminal using the same method you used earlier.
- Send a request without an API key by running:
curl -i --location --request GET http://localhost:54321/functions/v1/api
What should I see?
You should see status 401 with a JSON body containing Unauthorized. The missing key caused the middleware to stop the pipeline before the handler.
Still getting 200 without a key?
Confirm that withApiKey() appears in the pipeline after withCors().
Save index.ts again if the serving terminal did not reload the function.
Help me find why an unkeyed request still returns 200
Now the caller will send the same value held by the local environment. Before you run it, predict whether the request reaches the handler.
- Send the valid API key in the request header by running:
curl -i --location --request GET http://localhost:54321/functions/v1/api \
--header 'x-api-key: nextwork-demo-key'
What should I see?
You should see status 200 with the handler's original JSON response. The matching key allowed the request to continue through the pipeline.
You have the gate working. Anonymous requests stop at the middleware while trusted requests reach the handler.
Valid key still returns 401?
Check that nextwork-demo-key matches the value in supabase/functions/.env.local exactly. Confirm that the header name is x-api-key.
Restart the serve command after changing the environment file because environment values load when the runtime starts.
Help me debug a valid API key returning 401
One final check proves that authentication has not blocked browser preflight. Before you run it, do you expect the request to reach the API key guard?
- Send an OPTIONS preflight request without an API key by running:
curl -i --location --request OPTIONS http://localhost:54321/functions/v1/api \
--header 'Origin: https://app.example.com' \
--header 'Access-Control-Request-Method: GET'
What should I see?
You should see a successful preflight response with the existing Access-Control-Allow-Origin, Access-Control-Allow-Headers, and Access-Control-Allow-Methods headers.
This proves that CORS answers OPTIONS before the API key middleware checks normal requests.
- Switch back to the terminal serving the Edge Function.
- Confirm that the logger printed one new line for each test request.
Your local endpoint now returns 401 without a valid key. It returns 200 with x-api-key: nextwork-demo-key while preserving preflight handling.
Your API now separates anonymous callers from trusted callers. Next, you will pass typed authentication data to the handler.
Pass Typed Auth Data
Your local Supabase Edge Function already blocks requests that lack a valid API key. However, the middleware discards that verified value before your handler runs.
This step passes the trusted key through a typed context object. TypeScript can then catch a misspelled auth field before the request reaches your API.
In this step, get ready to:
- Define shared types for handlers and middleware.
- Pass verified API key data into the next handler.
- Prove that invalid fields fail before valid requests succeed.
Define the typed contracts
A typed context describes data that middleware has verified for downstream code. These shared Web Fetch contracts keep every layer aligned on the request shape.
- Create supabase/functions/api/middleware/types.ts from your code editor's file tree.
- Add the typed contracts by copying this code into the new file:
// Defines trusted authentication data for downstream handlers
export interface AuthContext {
apiKey: string;
}
// Describes a standard Fetch handler without extra context
export type Handler = (
request: Request,
) => Promise<Response>;
// Describes a handler that receives typed middleware context
export type NextHandler<TContext> = (
request: Request,
context: TContext,
) => Promise<Response>;
// Describes middleware that wraps one standard handler
export type Middleware = (
next: Handler,
) => Handler;
What do these types define?
- The AuthContext interface guarantees that trusted auth data contains an apiKey string.
- The Handler type describes an ordinary request handler.
- The generic NextHandler type adds a context value with a shape chosen by the middleware.
- The Middleware type keeps wrapper functions consistent.
- Save supabase/functions/api/middleware/types.ts.
- Check your editor's diagnostics for the new file.
You should see the new file in the middleware folder with no type errors.
Seeing a type error in types.ts?
Check that each type name matches the code exactly. Confirm that the generic name uses matching angle brackets.
Ask for help with the typed contracts.
The logger can now use the shared handler contracts. Its runtime behaviour stays the same while its input becomes explicit.
- Select supabase/functions/api/middleware/logger.ts from your code editor's file tree.
- Replace its contents with this typed logger:
import type { Handler, Middleware } from "./types.ts";
// Wrap each request without changing its response
export const withLogger: Middleware = (next: Handler): Handler => {
return async (request: Request): Promise<Response> => {
// Log before control passes to the next middleware
console.log(`${request.method} ${request.url}`);
return await next(request);
};
};
What changed in the logger?
- The type-only import connects the logger to the shared contracts without adding runtime code.
- The Middleware annotation confirms that the wrapper accepts one Handler and returns another.
- The logger still records the request before calling the next layer.
- Save supabase/functions/api/middleware/logger.ts.
- Check the function server terminal for a successful reload.
The local function should reload without reporting a compilation problem.
Logger failing after the import?
Confirm that types.ts sits beside logger.ts in the middleware folder. Check that the import path begins with ./.
Ask for help with the logger types.
Pass auth context through the middleware
The API key middleware already knows which key passed validation. It can package that value as AuthContext before it calls the protected handler.
- Select supabase/functions/api/middleware/apiKey.ts from your code editor's file tree.
- Replace its contents with this typed middleware:
import type { AuthContext, Handler, NextHandler } from "./types.ts";
// Validate the request before creating trusted auth context
export function withApiKey(
next: NextHandler<AuthContext>,
): Handler {
return async (request: Request): Promise<Response> => {
// Read the supplied key and the expected local value
const apiKey = request.headers.get("x-api-key");
const expectedApiKey = Deno.env.get("API_KEY");
// Stop requests that do not present the expected key
if (!apiKey || apiKey !== expectedApiKey) {
return Response.json(
{ error: "Unauthorized" },
{ status: 401 },
);
}
// Pass only validated auth data to the protected handler
const auth: AuthContext = { apiKey };
return await next(request, auth);
};
}
How does the context move?
- The middleware accepts a NextHandler<AuthContext> because the protected handler needs trusted auth data.
- The header check still compares x-api-key with the API_KEY environment value.
- The auth object is created only after validation succeeds.
- The final call passes the request plus the typed context to the next handler.
- Save supabase/functions/api/middleware/apiKey.ts.
Before you test this middleware, do you expect a request without the key to reach the protected handler?
- Send a request without the API key by running this command:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/api'
What does this check prove?
You should receive a 401 response with {"error":"Unauthorized"} in the body. The typed context is created only for a validated request.
Request reaching the handler without a key?
Confirm that the server reloaded after you saved apiKey.ts. Check that the middleware still returns immediately from the failed validation branch.
Ask for help with the rejected request.
Use the typed data in the handler
The protected handler can now declare exactly which context it accepts. Reading auth.apiKey proves that the verified data crossed the middleware boundary.
- Select supabase/functions/api/index.ts from your code editor's file tree.
- Replace its contents with this typed handler stack:
import { withApiKey } from "./middleware/apiKey.ts";
import { withCors } from "./middleware/cors.ts";
import { withLogger } from "./middleware/logger.ts";
import type { AuthContext, NextHandler } from "./middleware/types.ts";
// Build the response from data validated by auth middleware
const handler: NextHandler<AuthContext> = async (
_request: Request,
auth: AuthContext,
): Promise<Response> => {
return Response.json({
message: "Authenticated request",
apiKey: auth.apiKey,
});
};
// Keep logging outside CORS so every request is recorded
Deno.serve(withLogger(withCors(withApiKey(handler))));
What does the typed handler do?
- The handler declares AuthContext as the shape of its second parameter.
- The response reads auth.apiKey from data that the middleware already validated.
- The middleware order leaves CORS before API key validation.
- The logger remains the outer layer so every request produces one log line.
- Save supabase/functions/api/index.ts.
- Check your editor's diagnostics for the handler.
You should see no type errors while the handler reads the declared apiKey field.
Before you create a deliberate mistake, do you think TypeScript will accept a field that is missing from AuthContext?
- Temporarily replace apiKey after auth. with userId.
- Check the edited expression in your code editor.
Your editor should mark auth.userId as a type error because userId is absent from AuthContext. This is the safety net working before runtime.
- Restore auth.userId to auth.apiKey.
- Save supabase/functions/api/index.ts.
The type error should disappear after the declared field is restored.
Before the final request, predict which value the handler can now place in its JSON response.
- Send a valid authenticated request by running this command:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/api' --header 'x-api-key: nextwork-demo-key'
What should the final request show?
You should receive a 200 response. Its body should contain {"message":"Authenticated request","apiKey":"nextwork-demo-key"}.
This response proves that the handler received the typed auth data created by the middleware.
- Switch back to the function server terminal.
You should see one new request log for the valid call. Your existing logging layer still runs around the typed handler.
Valid request still returning 401?
Confirm that supabase/functions/.env.local still contains the local API_KEY value. Check that the request header uses the exact x-api-key name.
Ask for help tracing the typed auth request.
✔️ Awesome, I've got everything!
Your typed middleware stack is complete. Confirm that every changed file is saved.
ⓧ I'd like to double check the full code
Compare supabase/functions/api/middleware/types.ts with this complete file.
// Defines trusted authentication data for downstream handlers
export interface AuthContext {
apiKey: string;
}
// Describes a standard Fetch handler without extra context
export type Handler = (
request: Request,
) => Promise<Response>;
// Describes a handler that receives typed middleware context
export type NextHandler<TContext> = (
request: Request,
context: TContext,
) => Promise<Response>;
// Describes middleware that wraps one standard handler
export type Middleware = (
next: Handler,
) => Handler;
These contracts define the trusted context plus the handler signatures used by the stack.
Compare supabase/functions/api/middleware/logger.ts with this complete file.
import type { Handler, Middleware } from "./types.ts";
// Wrap each request without changing its response
export const withLogger: Middleware = (next: Handler): Handler => {
return async (request: Request): Promise<Response> => {
// Log before control passes to the next middleware
console.log(`${request.method} ${request.url}`);
return await next(request);
};
};
The logger uses the shared middleware type while preserving its request log.
Compare supabase/functions/api/middleware/apiKey.ts with this complete file.
import type { AuthContext, Handler, NextHandler } from "./types.ts";
// Validate the request before creating trusted auth context
export function withApiKey(
next: NextHandler<AuthContext>,
): Handler {
return async (request: Request): Promise<Response> => {
// Read the supplied key and the expected local value
const apiKey = request.headers.get("x-api-key");
const expectedApiKey = Deno.env.get("API_KEY");
// Stop requests that do not present the expected key
if (!apiKey || apiKey !== expectedApiKey) {
return Response.json(
{ error: "Unauthorized" },
{ status: 401 },
);
}
// Pass only validated auth data to the protected handler
const auth: AuthContext = { apiKey };
return await next(request, auth);
};
}
The API key middleware creates AuthContext only after the header passes validation.
Compare supabase/functions/api/index.ts with this complete file.
import { withApiKey } from "./middleware/apiKey.ts";
import { withCors } from "./middleware/cors.ts";
import { withLogger } from "./middleware/logger.ts";
import type { AuthContext, NextHandler } from "./middleware/types.ts";
// Build the response from data validated by auth middleware
const handler: NextHandler<AuthContext> = async (
_request: Request,
auth: AuthContext,
): Promise<Response> => {
return Response.json({
message: "Authenticated request",
apiKey: auth.apiKey,
});
};
// Keep logging outside CORS so every request is recorded
Deno.serve(withLogger(withCors(withApiKey(handler))));
The entry file consumes the typed context while preserving the existing middleware order.
Your auth middleware now passes trusted data through a checked TypeScript boundary. Next, you will deploy this same middleware stack behind a public URL.
Deploy the Middleware API
Your local middleware stack now logs each request. It also answers CORS preflights.
Deploying the same code as a Supabase Edge Function gives your API a public URL. The hosted runtime also needs the API key that your authentication layer expects.
In this step, get ready to:
- Link the repository to your Supabase project.
- Configure the hosted API_KEY secret before deploying the api function.
- Prove the deployed stack matches its local behavior.
Link the project and set the secret
A project link tells the Supabase CLI which hosted project should receive your function. A production environment variable gives the hosted middleware access to its expected key.
- Switch back to the terminal that is serving the local function.
- Stop the local serving process by pressing Ctrl+C.
- List the projects available to your authenticated account by running this command:
supabase projects list
What does this command show?
The command lists the Supabase projects that your authenticated account can access. Each project includes the reference used by remote CLI commands.
- Find the project that should host this API.
- Record its reference here: <project-ref>.
The next command can ask for your database password. A blank-looking password prompt is normal because the terminal hides every character you type.
- Link this repository to the selected project by running:
supabase link --project-ref [[PROJECT_REF="<project-ref>"]]
What does linking change?
The link makes this hosted project the target for later secret commands. It also becomes the target for your function deployment.
Your local files stay inside the repository. Linking only establishes the remote project context for the CLI.
The next command stores the fixed demo credential in the hosted project. Real production credentials should use unique values that never enter source control.
- Set the hosted API key secret by running:
supabase secrets set API_KEY=nextwork-demo-key
How does the function receive the key?
Supabase makes API_KEY available to the deployed Edge Function. The API key middleware reads this value when a request reaches the function.
The local supabase/functions/.env.local file remains on your machine. The hosted secret supplies the equivalent production value.
Before you check, do you expect the secret list to reveal the credential or protect its value?
- Verify the hosted secret by running:
supabase secrets list
What should you see?
The output should list API_KEY among the project secrets. Supabase keeps the secret value protected.
Project link or secret failing?
Confirm that the selected project belongs to the account authenticated in the CLI. Check that your account can manage the project's Edge Function secrets.
Run the project list command again if you selected the wrong project reference.
Help me diagnose my Supabase project link or secret command.
Deploy the typed API
Supabase verifies a platform JWT before running an Edge Function by default. This API uses its own reusable API key middleware as the authentication boundary.
Turning off the platform JWT check can feel risky. Your middleware still rejects every request that lacks the expected x-api-key value.
- Deploy the api function with platform JWT verification disabled by running:
supabase functions deploy api --no-verify-jwt
Why disable JWT verification?
The --no-verify-jwt flag lets requests reach your custom middleware without a Supabase JWT. The API key middleware then applies the authentication rule you built.
The deployment bundles the typed handler with every middleware file used by the api function. A successful CLI message confirms that the hosted function received the bundle.
- Record the deployed function URL here: https://<project-ref>.supabase.co/functions/v1/api.
Deployment not completing?
Confirm that the terminal is still inside the repository containing the supabase directory. Check the project link if the CLI cannot identify a deployment target.
Help me diagnose my Edge Function deployment.
Verify the public middleware stack
The public endpoint should preserve the response path you proved locally. You will test the preflight response before checking both authentication outcomes.
Before you send the first request, do you think the CORS middleware will answer before authentication runs?
- Send an OPTIONS request to the public endpoint by running:
curl -i --request OPTIONS '[[API_URL="https://<project-ref>.supabase.co/functions/v1/api"]]'
What does the preflight prove?
- The response includes Access-Control-Allow-Origin.
- The response includes Access-Control-Allow-Headers.
- The response includes Access-Control-Allow-Methods.
These headers prove that the CORS layer handles the browser preflight before the API key check.
Before the next request, do you expect the gateway or your custom middleware to produce the authentication response?
- Call the public endpoint without an API key by running:
curl -i '[[API_URL="https://<project-ref>.supabase.co/functions/v1/api"]]'
Which layer rejected the request?
You should receive a JSON response with status 401. This response proves that requests reach your custom API key middleware.
Before the final request, do you expect the hosted response to contain the same typed authentication data as the local response?
- Call the public endpoint with the valid API key by running:
curl -i --header 'x-api-key: nextwork-demo-key' '[[API_URL="https://<project-ref>.supabase.co/functions/v1/api"]]'
What does the successful response prove?
You should receive a JSON response with status 200. The response contains the typed authentication data created from AuthContext.apiKey.
The hosted function now matches the successful response you tested locally.
You have now proved production parity for the preflight response. You have also proved both authentication outcomes.
- Return to the linked project in the Supabase Dashboard.
- Select Functions from the project navigation.
- Select the api function.
- Select the Logs view.
- Confirm that recent log entries include request methods from your public tests.
You should see the deployed endpoint URL in the logger output. This confirms that the logger middleware also runs in the hosted function.
Public responses do not match?
A platform authentication response can mean the function was deployed without --no-verify-jwt. Deploy the function again with the verified command above.
A custom 401 response from the valid request points to the hosted API_KEY secret. Set the demo value again before repeating the request.
Help me compare my deployed responses with the local API.
That is the complete middleware path running on a public URL. Your logger, CORS handler, API key check, typed context, and handler now behave consistently beyond your local machine.
Secret mission
Audit the Deployed Middleware Stack
Turn the deployed endpoint into a four-case middleware audit. Prove that preflight bypasses authentication. Confirm that only the configured demo key reaches the typed handler.
Clean Up Your Resources
Clean Up Your Resources
This project follows a free-tier path, so the main flow has no expected ongoing cost. Choose whether to keep the public API available, pause your local work, or remove the resources.
Resources you used:
- The deployed Supabase Edge Function named api.
- The API_KEY secret configured in your linked Supabase project.
- The local repository containing the Edge Function code plus reusable middleware files.
- The local supabase/functions/.env.local file stored inside the repository.
Keep everything running
No action is needed. Choose this option while the endpoint remains useful or the middleware is still reference material.
- Leave the deployed api Edge Function in your linked Supabase project.
- Keep the API_KEY secret configured while the function remains public.
- Keep the local repository so you can reuse the logger, CORS, API key, or type-contract middleware.
- Keep supabase/functions/.env.local uncommitted because it contains the local secret value.
Pause - I'll come back to this later
Stop local processes to free up memory while preserving your files. The deployed Edge Function has no local process to pause.
- Switch back to any terminal that is still serving the Edge Function locally.
- Press Ctrl+C to stop the local serving process.
- Close the terminal window when you finish working.
- Leave the deployed api Edge Function in place if you still need its public URL.
- Keep the local repository for your next middleware project.
Any local serving process is now stopped. The deployed URL remains available.
Delete - I don't want to use this again
This option removes the deployed function plus its project secret. It also removes the local repository from your machine.
Deletion Is Permanent
Deleting the function removes the public API. Your Supabase account plus the linked project remain available.
The Keep option preserves the reusable middleware if you want it for another API.
- Return to your linked project in the Supabase Dashboard.
- Use the project navigation to open the Edge Functions area.
- Select the deployed api function.
- Choose the function's delete control.
- Confirm the deletion when Supabase asks.
- Reload the deployed endpoint from earlier to verify the deletion.
You should no longer receive the typed JSON response from the api function.
- Return to the Edge Functions area in your linked project.
- Open the secrets settings for Edge Functions.
- Find the API_KEY secret.
- Choose the secret's delete control.
- Confirm the deletion when Supabase asks.
The remaining resource is your local repository. Use the instructions for your operating system to remove it.
macOS
- Press Cmd+Space to open Spotlight.
- Enter Finder into Spotlight.
- Press Enter to open Finder.
- Locate the repository folder that contains supabase/functions/api/index.ts.
- Move the repository folder to Trash.
- Empty Trash to complete the deletion.
You should no longer find supabase/functions/api/index.ts or supabase/functions/.env.local in the deleted repository location.
Windows
- Press the Windows key to open search.
- Enter File Explorer into search.
- Press Enter to open File Explorer.
- Locate the repository folder that contains supabase/functions/api/index.ts.
- Move the repository folder to the Recycle Bin.
- Empty the Recycle Bin to complete the deletion.
You should no longer find supabase/functions/api/index.ts or supabase/functions/.env.local in the deleted repository location.
Nice Work!
Nice Work!
Congratulations. Your typed API now uses a reusable middleware stack locally. The same stack now protects a public Supabase Edge Function.
You've learned how to:
- Compose Fetch-standard middleware around one typed handler. Keep every layer reusable in its own file.
- Use request logs to trace incoming calls. Inspect CORS headers to confirm browser access rules. Check status codes to verify API key authentication.
- Pass typed authentication context into the handler. Catch invalid field names in the editor before runtime. Deploy the same behavior through a public Supabase Edge Function.
- Secret Mission: Took on an optional challenge to push the reusable middleware pattern further.
Ready to quiz yourself?