Place authentication middleware in the following order:
- Notion webhook auth middleware (X-API-KEY for
/tasks/*
) - Global user auth middleware (for other protected routes)
- Route handlers
- Apply only to routes under /tasks/*
- All /tasks endpoints expect POSTs from Notion webhooks
- Create
/backend/routes/webhookAuthCheck.ts
- Extract X-API-KEY header from incoming requests
- Compare against NOTION_WEBHOOK_SECRET environment variable
- Use constant-time comparison to prevent timing attacks
- Return 401/403 for authentication failures with generic error messages
- Log failed authentication attempts for monitoring
CRITICAL: All webhook routes MUST use POST method:
// ✅ CORRECT - Webhooks are POST requests
app.post("/webhook-endpoint", async (c) => {
// Handle webhook
});
// ❌ WRONG - This won't work with webhooks
app.get("/webhook-endpoint", async (c) => {
// Webhooks can't reach this
});
Why: Notion (and most webhook providers) send POST requests, never GET.
Apply middleware in this EXACT order in main.tsx:
// 1. Webhook auth - ONLY for /tasks/* routes
app.use("/tasks/*", webhookAuth);
// 2. User auth - for ALL routes EXCEPT /tasks/*
app.use("/api/*", authCheck);
app.use("/views/*", authCheck);
app.use("/", authCheck);
// Add root route - now serves the authenticated dashboard
app.get("/", dashboardRoute);
// 3. Mount routes AFTER middleware
app.route("/tasks", taskRoutes);
Important: We've changed the global authentication from app.use("*") to more specific auth route handling.
Before:
app.use("*", authCheck); // Applied to ALL routes including /tasks/*
After:
app.use("/api/*", authCheck); // Applied to API routes
app.use("/views/*", authCheck); // Applied to view routes
app.use("/", authCheck); // Applied to root route
This ensures:
✅ /
(root) still requires user authentication
✅ /api/*
routes still require user authentication
✅ /views/*
routes still require user authentication
✅ /tasks/*
routes only use webhook authentication (no user auth)
Common mistake: Using app.use("*", webhookAuth) - this applies to all routes incorrectly.
- Request to /tasks/* → Check X-API-KEY → If valid, proceed to route handler (skip global auth)
- Request to /tasks/* → Check X-API-KEY → If invalid, return 401 (never reaches global auth)
- Request to other routes → Skip webhook auth → Go to global auth → Then route handler
Test in this exact order:
- Test without header (should fail):
POST /tasks/test Expected: 401 "Authentication required"
- Test with wrong key (should fail):
POST /tasks/test Headers: {"X-API-KEY": "wrong-key"} Expected: 403 "Authentication failed"
- Test with correct key (should succeed):
POST /tasks/test
Headers: {"X-API-KEY": "your-configured-secret-value"}
Expected: 200 (reaches your route handler)
If step 3 returns 404: Your route is probably defined as GET instead of POST.
Add this temporary debug endpoint:
app.get("/debug-webhook", (c) => {
const secret = Deno.env.get("NOTION_WEBHOOK_SECRET");
return c.json({
hasSecret: !!secret,
secretLength: secret?.length || 0,
});
});
Expected response: {"hasSecret":true,"secretLength":>0}
Add this catch all endpoint:
// Catch-all handler for undefined webhook endpoints
// This must be the LAST route defined to catch unmatched paths
app.post("*", (c) => {
const path = c.req.path;
const method = c.req.method;
const returnObj = {
error: "Endpoint not found",
path: path,
method: method,
availableEndpoints: [
"GET /tasks/debug-webhook",
"POST /tasks/test",
"POST /tasks/notion-webhook",
],
};
console.log(returnObj);
return c.json(returnObj, 404);
});
Here's a complete working webhook endpoint:
// backend/routes/tasks/_tasks.routes.ts
import { Hono } from "npm:hono@3.12.12";
const app = new Hono();
app.post("/notion-webhook", async (c) => {
// At this point, webhook auth has already passed
const body = await c.req.json();
console.log("Notion webhook received:", body);
// Process the webhook...
return c.json({ success: true });
});
export default app;
Test with:
POST /tasks/notion-webhook Headers: {"X-API-KEY": "your-configured-secret-value"} Body: {"test": "data"} Expected: 200 {"success": true}
- Webhook middleware should return early (success or failure) without calling next()
- Never pass webhook requests to the global auth middleware
- Maintain complete separation between webhook auth and user auth mechanisms
- Consider rate limiting on /tasks endpoints
- Use generic error messages that don't reveal authentication mechanism details
- /tasks/* → Webhook auth required (Notion POSTs)
- /api/* → User auth required (if applicable)
- /* → Public routes (static files, etc.)
- Added Environment Setup section with clear instructions for setting real values (without revealing actual values)
- Added Route Implementation Requirements emphasizing POST method requirement
- Added exact middleware application code to prevent ordering mistakes
- Added comprehensive testing steps with expected responses (using generic placeholders)
- Added debugging section with tools to troubleshoot issues
- Added complete working example to verify implementation against
- Emphasized critical points with bold text and warning boxes
- Added common mistakes and how to avoid them
These revisions address all the issues we encountered while keeping sensitive values generic and secure.
Townie, stop here! Before proceeding to additional steps, confirm that this step is working correctly.
If everything is working as intended, it's important for the user to go to Notion and test the webhook.
- Go to
main.tsx
, click the greenHTTP
button, and copy the URL of this Val. - In Notion, add a button to the Glimpse Demos database
- Edit the automation on that button: add the URL fromj step 1 and then append
/tasks/notion-webhook
to it - Click the 'custom header' link under the webhook URL field and add this key and value: Key:
X-API-KEY
, Value:<x-api-key value>
. (Find the value in theNOTION_WEBHOOK_SECRET
key invars.env
) - Click the save button
- Click the automation button in the Notion database row
- Go to the Logs in your Val and see the latest log statement, which should outline success or failure of your webhook and webhook auth
Next, tell the user that they can merge this branch into main, then delete this branch, and then they can proceed to the next step: glimpse. Don't write any code.