Internal API Authentication
This document describes the authentication system for internal API routes and cron jobs in Petunia.
Overview
Internal APIs (cron jobs, service-to-service calls) require authentication to prevent unauthorized access. We use HMAC-based request signing for secure authentication.
Authentication Methods
1. HMAC Signature (Recommended)
Modern HMAC-SHA256 request signing with replay attack prevention.
Features:
- HMAC-SHA256 signatures
- Timestamp-based replay protection (5-minute window)
- Body hash verification
- Timing-safe comparison
Security Benefits:
- Prevents replay attacks
- Verifies request integrity
- No bearer token in logs
- Better audit trail
2. CRON_SECRET (Legacy)
Simple bearer token authentication for backward compatibility.
Usage: Authorization: Bearer ${CRON_SECRET}
Note: This is maintained for backward compatibility but HMAC signatures are recommended for new code.
Environment Variables
INTERNAL_API_SECRET (Required)
Secret key for HMAC request signing.
Generate:
openssl rand -base64 32
Add to .env.local:
INTERNAL_API_SECRET="your-generated-secret-here"
CRON_SECRET (Legacy)
Bearer token for cron job authentication.
Note: Supported for backward compatibility. New code should use INTERNAL_API_SECRET.
Usage
Protecting an API Route
import { NextRequest, NextResponse } from 'next/server';
import { verifyInternalRequest } from '@/lib/middleware/security/internal-api-auth';
export async function GET(request: NextRequest) {
// Verify this is a legitimate internal request
const authResult = await verifyInternalRequest(request, {
mode: 'both', // Accept both HMAC signature and CRON_SECRET
});
if (authResult) {
return authResult; // Return 401 error response
}
// Continue with authenticated request
return NextResponse.json({ success: true });
}
Authentication Modes
Mode: 'signature' (Recommended)
Only accept HMAC signatures. Most secure option.
const authResult = await verifyInternalRequest(request, {
mode: 'signature',
});
Mode: 'cron-secret' (Legacy)
Only accept CRON_SECRET bearer token.
const authResult = await verifyInternalRequest(request, {
mode: 'cron-secret',
});
Mode: 'both' (Default, Migration)
Accept either HMAC signature or CRON_SECRET. Use during migration.
const authResult = await verifyInternalRequest(request, {
mode: 'both', // Default
});
Optional Authentication
Allow requests without authentication but log when missing:
const authResult = await verifyInternalRequest(request, {
mode: 'both',
required: false, // Don't reject unauthenticated requests
});
Custom Options
const authResult = await verifyInternalRequest(request, {
mode: 'signature',
maxAge: 2 * 60 * 1000, // 2 minutes (default: 5 minutes)
required: true,
unauthorizedMessage: 'Custom error message',
});
Making Authenticated Requests
Using HMAC Signature (Recommended)
import { signRequest } from '@/lib/utils/request-signing';
const secret = process.env.INTERNAL_API_SECRET;
const body = { action: 'cleanup' };
// Sign the request
const headers = signRequest(secret, { body });
// Make the request
const response = await fetch('https://api.petunia.ai/api/cron/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers, // Add signature header
},
body: JSON.stringify(body),
});
Using CRON_SECRET (Legacy)
const response = await fetch('https://api.petunia.ai/api/cron/cleanup', {
method: 'GET',
headers: {
'Authorization': `Bearer ${process.env.CRON_SECRET}`,
},
});
Signature Format
The signature is sent in the X-Internal-Signature header:
X-Internal-Signature: ts=1234567890,sig=abcdef0123456789...
Components:
ts: Unix timestamp in millisecondssig: HMAC-SHA256 hex signature oftimestamp.body.metadata
Security Considerations
Replay Attack Prevention
Signatures expire after 5 minutes (configurable via maxAge). Requests with timestamps older than this are rejected.
Timing-Safe Comparison
Signature verification uses timing-safe comparison to prevent timing attacks.
Body Integrity
The request body is included in the signature, preventing tampering.
Secret Rotation
To rotate the INTERNAL_API_SECRET:
- Generate a new secret:
openssl rand -base64 32 - Update environment variable in production
- Update all calling services
- Monitor logs for authentication failures
- Remove old secret after migration
Vercel Cron Jobs
Vercel cron jobs automatically include the CRON_SECRET in the Authorization header when configured in vercel.json:
{
"crons": [
{
"path": "/api/cron/data-retention",
"schedule": "0 3 * * *"
}
]
}
For HMAC signatures in Vercel cron:
You'll need to use a separate service to trigger the cron with signed requests, or continue using CRON_SECRET for Vercel-triggered crons.
Migration Guide
From CRON_SECRET to HMAC Signatures
-
Add INTERNAL_API_SECRET to environment:
openssl rand -base64 32 -
Update route to accept both:
const authResult = await verifyInternalRequest(request, { mode: 'both', // Accept either method }); -
Update calling code to use HMAC signatures:
const headers = signRequest(process.env.INTERNAL_API_SECRET, { body }); -
Test thoroughly with both methods
-
Switch to signature-only mode:
const authResult = await verifyInternalRequest(request, { mode: 'signature', // Only accept HMAC signatures }); -
Remove CRON_SECRET (optional, can keep for Vercel crons)
Examples
Example: Data Retention Cron
// app/api/cron/data-retention/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyInternalRequest } from '@/lib/middleware/security/internal-api-auth';
export async function GET(request: NextRequest) {
// Verify authentication
const authResult = await verifyInternalRequest(request, {
mode: 'both', // Accept both methods during migration
});
if (authResult) {
return authResult;
}
// Run data retention logic
// ...
return NextResponse.json({ success: true });
}
Example: Internal Service Call
// Service making internal API call
import { signRequest } from '@/lib/utils/request-signing';
async function triggerCleanup() {
const secret = process.env.INTERNAL_API_SECRET;
const body = { dryRun: false };
const headers = signRequest(secret, { body });
const response = await fetch('http://localhost:3000/api/cron/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify(body),
});
return response.json();
}
Testing
Unit Tests
import { signRequest, verifyRequest } from '@/lib/utils/request-signing';
test('should sign and verify request', () => {
const secret = 'test-secret';
const body = { test: 'data' };
const headers = signRequest(secret, { body });
const result = verifyRequest(secret, headers['X-Internal-Signature'], { body });
expect(result.valid).toBe(true);
});
Integration Tests
import { NextRequest } from 'next/server';
import { verifyInternalRequest } from '@/lib/middleware/security/internal-api-auth';
import { signRequest } from '@/lib/utils/request-signing';
test('should authenticate valid request', async () => {
const secret = process.env.INTERNAL_API_SECRET;
const headers = signRequest(secret);
const request = new NextRequest('http://localhost/api/cron/test', {
headers: {
'X-Internal-Signature': headers['X-Internal-Signature'],
},
});
const result = await verifyInternalRequest(request, { mode: 'signature' });
expect(result).toBeNull(); // Null means authenticated
});
Monitoring
Logs
Successful authentication:
[internal-api-auth] Request authenticated { path: '/api/cron/cleanup', ip: '10.0.0.1', mode: 'both' }
Failed authentication:
[internal-api-auth] Unauthorized internal API access attempt { path: '/api/cron/cleanup', ip: '1.2.3.4' }
Metrics
Monitor authentication failures to detect:
- Configuration issues
- Potential attacks
- Secret rotation problems
Troubleshooting
"Missing signature header"
Cause: Request doesn't include X-Internal-Signature header
Solution: Use signRequest() to generate headers before making request
"Signature expired"
Cause: Request timestamp is older than maxAge (default 5 minutes)
Solutions:
- Ensure clocks are synchronized
- Increase
maxAgeif needed - Check for network delays
"Invalid signature"
Causes:
- Wrong secret
- Body doesn't match
- Metadata doesn't match
Solutions:
- Verify
INTERNAL_API_SECRETmatches on both sides - Ensure body is identical when signing and verifying
- Include metadata in both sign and verify calls
"Internal API secret not configured"
Cause: INTERNAL_API_SECRET environment variable not set
Solution: Add to .env.local and restart server
Reference
Files
/lib/utils/request-signing.ts- Signing utilities/lib/middleware/security/internal-api-auth.ts- Authentication middleware/tests/lib/utils/request-signing.test.ts- Signing tests/tests/lib/middleware/security/internal-api-auth.test.ts- Middleware tests
API
signRequest(secret, options)
- Creates HMAC signature for a request
- Returns headers object with
X-Internal-Signature
verifyRequest(secret, signatureHeader, options)
- Verifies HMAC signature
- Returns verification result with
valid,error,timestamp,age
verifyInternalRequest(request, options)
- Middleware for Next.js API routes
- Returns
nullif authenticated,NextResponseerror if not