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:

1. You send: { filename: 'photo.jpg' }
2. Attacker intercepts (man-in-the-middle)
3. Changes to: { filename: '../../etc/passwd' }
4. We receive modified request, think it's from you
❌ Result: Path traversal attack succeeds

With request signing:

1. You send: { filename: 'photo.jpg' } + signature of this exact body
2. Attacker intercepts, modifies body
3. Signature no longer matches modified body
4. We detect tampering, reject request
✅ Result: Attack blocked, you get 401 Unauthorized

How 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.

Step 1: Build the message
message = "POST|/api/v1/upload|1704672000123|{"filename":"photo.jpg"}"
Format: METHOD | PATH | TIMESTAMP | BODY
Step 2: Hash with your secret
signature = HMAC-SHA256(message, your_secret)
Output: "a3f2b9c1d4e5f6a7b8c9d0e1f2a3b4c5..." (64 hex characters)
Step 3: We verify
We rebuild the same message, hash it with your secret (from our database), compare signatures.
If match → Valid ✅ | If mismatch → Tampered ❌

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

Empty body (GET/DELETE requests)
// 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 object key order
JavaScript objects don't guarantee key order. Use 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}`;
We use the exact body string you send. Don't re-serialize it differently when verifying.
Whitespace in JSON
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"}
Path normalization
Always use the exact path from your request:
// ❌ 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)

vs JWT:
JWT is great for sessions (user logged in, here's a token). We're doing API authentication (every request needs verification). HMAC is simpler, faster, and more secure for this use case.
vs OAuth:
OAuth is for delegated access ("let this app access your Google Drive"). We're doing direct API access (you own the account, you make requests). OAuth adds complexity we don't need.
Why HMAC-SHA256:
  • 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)