Request Signing
Why we sign requests, not just authenticate them
Authentication proves who you are. Signing proves the request hasn't been tampered with.
Example attack without signing:
{ filename: 'photo.jpg' }{ filename: '../../etc/passwd' }With request signing:
{ filename: 'photo.jpg' } + signature of this exact bodyHow HMAC-SHA256 works (simple explanation)
HMAC = Hash-based Message Authentication Code. It's a way to create a "fingerprint" of your request that only you can create.
Timestamp validation (replay attack prevention)
Every request includes a timestamp. We only accept requests within 5 minutes of the current time.
// Client side:
const timestamp = Date.now(); // 1704672000123 (Unix ms)
headers['X-Timestamp'] = timestamp.toString();
// Server side:
const now = Date.now();
const requestTime = parseInt(headers['X-Timestamp']);
const age = Math.abs(now - requestTime);
if (age > 5 * 60 * 1000) { // 5 minutes in milliseconds
return res.status(401).json({
error: 'Request timestamp expired',
hint: 'Timestamp must be within 5 minutes of current time'
});
}Why this matters:
- Attacker captures your request at 10:00 AM
- Tries to replay it at 10:10 AM (10 minutes later)
- Timestamp is 10 minutes old → Rejected
- Even though signature is valid, request is too old
Clock skew tolerance:
We allow ±5 minutes to account for:
- Server clocks slightly out of sync
- Network latency (request takes a few seconds to arrive)
- Time zone confusion (always use UTC/Unix timestamps)
If your clock is off by ~5 minutes, you'll get 401: Timestamp expired. Fix: Sync your system clock with NTP.
What gets signed (and what doesn't)
Included in signature:
- HTTP method — GET, POST, DELETE, etc.
- Request path — /api/v1/upload/r2/signed-url
- Timestamp — Unix milliseconds
- Request body — Full JSON payload
NOT included in signature:
- Query parameters — We don't use query params in API, only body
- Headers (except timestamp) — Can be modified by proxies
- User agent — Not security-relevant
Why this matters: If we included headers, a proxy could add/modify headers and break the signature. By only signing method, path, timestamp, and body, we ensure the signature is stable.
Edge cases and gotchas
// For GET/DELETE (no body):
const message = `${method}|${path}|${timestamp}|`; // Empty string after last |
// Example:
"GET|/api/v1/upload/list|1704672000123|"
// NOT:
"GET|/api/v1/upload/list|1704672000123" // Missing trailing |JSON.stringify() consistently:// ❌ BAD (key order may vary):
const body = { filename: 'a.jpg', contentType: 'image/jpeg' };
const message = `POST|/path|${timestamp}|${JSON.stringify(body)}`;
// Could be: {"filename":"a.jpg","contentType":"image/jpeg"}
// Or: {"contentType":"image/jpeg","filename":"a.jpg"}
// ✅ GOOD (consistent):
const bodyString = JSON.stringify(body); // Consistent order
const message = `POST|/path|${timestamp}|${bodyString}`;JSON.stringify() doesn't add whitespace by default. Don't add it manually:// ❌ BAD:
const bodyString = JSON.stringify(body, null, 2); // Pretty-printed
// {"\n \"filename\": \"a.jpg\"\n}" (includes newlines/spaces)
// ✅ GOOD:
const bodyString = JSON.stringify(body); // Compact
// {"filename":"a.jpg"}// ❌ BAD (inconsistent): const path = '/api/v1/upload/r2/signed-url/'; // Trailing slash // vs const path = '/api/v1/upload/r2/signed-url'; // No trailing slash // ✅ GOOD (use URL object): const url = new URL(request.url); const path = url.pathname; // Always consistent
Full implementation example
import crypto from 'crypto';
// Your credentials (from environment variables)
const API_KEY = process.env.OBITOX_API_KEY;
const API_SECRET = process.env.OBITOX_API_SECRET;
// Generate signature for a request
function generateSignature(method, path, timestamp, body, secret) {
// Convert body to string
const bodyString = typeof body === 'string'
? body
: body
? JSON.stringify(body)
: '';
// Build message: METHOD|PATH|TIMESTAMP|BODY
const message = `${method.toUpperCase()}|${path}|${timestamp}|${bodyString}`;
// Sign with HMAC-SHA256
const signature = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
return signature;
}
// Make a signed request
async function makeSignedRequest(method, path, body = null) {
const timestamp = Date.now();
const signature = generateSignature(method, path, timestamp, body, API_SECRET);
const response = await fetch(`https://api.obitox.com${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'X-API-Secret': API_SECRET,
'X-Signature': signature,
'X-Timestamp': timestamp.toString()
},
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
const error = await response.json();
throw new Error(`API Error: ${error.error}`);
}
return response.json();
}
// Usage:
const result = await makeSignedRequest('POST', '/api/v1/upload/r2/signed-url', {
filename: 'photo.jpg',
contentType: 'image/jpeg',
r2AccessKey: '...',
r2SecretKey: '...',
r2Bucket: 'my-uploads'
});
console.log('Upload URL:', result.uploadUrl);Debugging signature mismatches
If you're getting 401: Invalid signature, try this:
// Add this before making request:
const message = `${method}|${path}|${timestamp}|${bodyString}`;
console.log('Message being signed:', message);
console.log('Signature:', signature);
console.log('Timestamp:', timestamp);
console.log('Body:', bodyString);
// Check:
// 1. Is method uppercase? (POST not post)
// 2. Is path correct? (/api/v1/... not /v1/...)
// 3. Is timestamp a number? (1704672000123 not "2024-01-08")
// 4. Is body exactly what you're sending?
// 5. Are you using the right secret?Still stuck? Email support@obitox.com with:
- Request ID (from error response)
- Message you signed (sanitize secrets!)
- Timestamp you used
- First 8 characters of your API key (e.g., ox_196ae...)
We'll check our logs and tell you exactly what we received vs what you signed.
Why we chose HMAC-SHA256 (not JWT, not OAuth)
- Simple to implement (10 lines of code)
- Fast to verify (~1ms)
- Cryptographically secure (SHA256 is battle-tested)
- Request-specific (signature changes per request)
- Tamper-evident (any change breaks signature)