⏺ Authentication Strategy for Protecting API and Webhook Endpoints
Overview
This authentication approach protects all /api/_ and /tasks/_ endpoints from unauthorized access while keeping specific endpoints (like /api/health) public. It uses a simple shared secret passed via HTTP header, with graceful degradation for development mode.
Components Required
- Environment Variable
- Name: WEBHOOK_SECRET
- Purpose: Shared secret for authenticating webhook and API requests
- Storage: Val Town environment variables (or your platform's env var system)
- Value: Any secure random string (32+ characters recommended)
- Behavior: If not set, authentication is bypassed (development mode with console warning)
- Authentication Middleware File
- Location: backend/routes/authCheck.ts
- Exports: webhookAuth function
- Framework: Hono middleware pattern (Context, Next)
- Middleware Function Logic The webhookAuth function should:
- Accept Hono Context and Next parameters
- Extract X-API-KEY header from request (case-insensitive: x-api-key)
- Read WEBHOOK_SECRET from environment variables
- If WEBHOOK_SECRET not configured:
- Log warning to console
- Call await next() to continue (bypass auth)
- Return
- If X-API-KEY header missing or doesn't match
secret:
- Return 401 JSON response with error message
- Do NOT call next()
- If authentication succeeds:
- Call await next() to continue to route handler
- Application in Main Entry Point
- Location: main.http.tsx
- Pattern: Array-based route protection with exceptions
Implementation approach:
-
Define protected route prefixes in array: ['/tasks', '/api']
-
Define public exceptions in array: ['/api/health']
-
Create single app.use('*', ...) middleware that:
- Extracts request path from context
- Checks if path is in public exceptions
(exact match) - If public, call await next() and return - Checks if path starts with any protected prefix - If protected, call webhookAuth(c, next) (delegates to auth middleware) - If not protected, call await next()
- Route Mounting Order Important: Apply authentication middleware BEFORE mounting routes
- Hono app initialization
- Error handler
- Static file serving (if needed)
- Authentication middleware (app.use)
- Route mounting (app.route)
Usage Patterns
For Webhook Callers (Notion buttons, external services):
- Add custom HTTP header to webhook configuration
- Header name: X-API-KEY
- Header value: The value of WEBHOOK_SECRET env var
- Example in Notion: Custom headers → Add → X-API-KEY = your-secret-value
For API Requests (curl, fetch, etc.): Include header: X-API-KEY: your-secret-value
For Internal Endpoint Calls:
- When one route needs to call another protected route internally
- Fetch the WEBHOOK_SECRET from environment: Deno.env.get('WEBHOOK_SECRET')
- Include in headers: { 'X-API-KEY': webhookSecret }
- Example: Background job triggers another endpoint
Key Design Decisions
Why array-based approach:
- Single middleware application point
- Easy to add/remove protected routes
- Easy to add/remove public exceptions
- Self-documenting (arrays clearly show what's protected)
- No need for multiple app.use() calls
Why shared secret vs. individual keys:
- Simpler for single-user/single-team apps
- No key management complexity
- Sufficient for webhook protection use case
- Can be upgraded to JWT/OAuth later if needed
Why graceful degradation:
- Allows development without auth setup
- Console warning alerts developer
- Production should always set the secret
Why /api/health is public:
- Needed for frontend dashboard to load without auth
- Doesn't expose sensitive write operations
- Shows configuration status (helpful for setup)
- Can be monitored externally without credentials
Security Considerations
What this protects:
- Prevents unauthorized webhook triggers
- Prevents unauthorized API data access
- Prevents page ID enumeration attacks
- Prevents write operations from bad actors
What this doesn't protect:
- Secret itself (must be transmitted securely)
- Doesn't prevent secret leakage if exposed in logs/errors
- Doesn't rate limit (consider adding separately)
- Doesn't expire (consider rotating secrets manually)
Recommendations:
- Never commit secrets to git
- Use environment variables only
- Log failed auth attempts for monitoring
Testing the Implementation
Test 1: Public endpoint works without auth
- Request /api/health with no headers
- Should return 200 with data
Test 2: Protected endpoint blocks without auth
- Request /api/pages/recent with no headers
- Should return 401 with error message
Test 3: Protected endpoint allows with valid auth
- Request /api/pages/recent with X-API-KEY: correct-secret
- Should return 200 with data
Test 4: Protected endpoint blocks with invalid auth
- Request /api/pages/recent with X-API-KEY: wrong-secret
- Should return 401 with error message
Test 5: Development mode warning
- Remove WEBHOOK_SECRET from environment
- Make any request
- Should see console warning and allow all requests
Documentation to Include
In README/Setup docs:
- List WEBHOOK_SECRET as required environment variable
- Explain what it protects and why it's important
- Show example of setting it
- Show example of using it in webhook configuration
- Explain security implications of not setting it
In code comments:
- Document which routes are protected
- Document which routes are exceptions
- Explain why each route is protected or public
- Include example header usage in comments
Migration Notes
If adapting to another framework:
- Middleware signature will differ (Express, Fastify, etc.)
- Header extraction method may differ
- Response format may differ
- Core logic remains the same
If adapting to different auth method:
- Keep array-based route protection pattern
- Replace shared secret check with JWT/OAuth validation
- Keep graceful degradation for development
- Keep public exceptions pattern
This approach is simple, secure enough for webhook protection, and easy to understand and maintain. It's appropriate for single-user or small-team applications where shared secrets are acceptable.