Skip to content
cd ..

Constant Time Authentication and Why It Matters

// · 5 min read

Authentication in AgenticMail has to handle two very different use cases. Admins need full system access to manage agents, configure settings, and monitor everything. Individual agents need scoped access limited to their own mailbox and tools. The auth system in auth.ts supports both through a two tier API key design, with constant time comparison at its core.

Two Key Types

AgenticMail issues two types of API keys:

Master keys are prefixed with mk_ and grant full administrative access. These are used by the dashboard, CLI tools, and any integration that needs to manage the system as a whole. Creating agents, viewing global stats, modifying configuration: all of that requires a master key.

Agent keys are prefixed with ak_ and are scoped to a single agent. An agent key can only access that agent’s emails, use that agent’s tools, and read that agent’s state. If an agent key leaks, the blast radius is limited to one agent’s data, not the entire system.

The prefix convention isn’t just cosmetic. When a request comes in, the auth middleware checks the prefix to determine which validation path to take. Master keys are compared against the stored admin credentials. Agent keys are looked up in the agents table and validated against the specific agent they claim to belong to. This means the system can quickly reject a request with an invalid prefix before doing any expensive database lookups.

Why Constant Time Comparison Matters

Here’s the security critical part. When you compare two strings in most programming languages, the comparison typically stops at the first character that doesn’t match. If the first character is wrong, it returns false almost instantly. If the first 30 characters are correct and only the 31st is wrong, it takes slightly longer.

That timing difference is tiny (nanoseconds), but it’s measurable. An attacker can submit thousands of authentication attempts, carefully measure the response times, and work out the correct key one character at a time. This is called a timing attack, and it’s not theoretical. It’s been demonstrated against real systems.

The fix is to always take the same amount of time regardless of where the mismatch occurs. Node.js provides crypto.timingSafeEqual() for exactly this purpose. It compares two buffers byte by byte and always processes every byte before returning a result. Whether the key is completely wrong or off by one character, the comparison takes the same time.

But there’s a catch: timingSafeEqual requires both buffers to be the same length. If they’re different lengths, it throws an error, and the absence of a comparison leaks the fact that the lengths didn’t match. That’s still a timing side channel.

AgenticMail solves this by hashing both the submitted key and the stored key with SHA 256 before comparison. SHA 256 always produces a 32 byte output regardless of input length. So even if someone submits a 5 character key and the real key is 64 characters, both get hashed to 32 bytes and timingSafeEqual can compare them without leaking length information.

const submittedHash = crypto
  .createHash('sha256')
  .update(submittedKey)
  .digest();
const storedHash = crypto
  .createHash('sha256')
  .update(storedKey)
  .digest();
return crypto.timingSafeEqual(submittedHash, storedHash);

Activity Tracking Throttle

Every authenticated request could potentially update the “last active” timestamp for the key or agent. But writing to the database on every single API call is expensive, especially for agents that make rapid fire requests during a conversation turn.

The auth module throttles activity tracking to once per 60 seconds. When a key is successfully validated, the system checks if the last recorded activity timestamp is more than 60 seconds old. If so, it updates it. If not, it skips the write. This means the “last active” time is accurate to within a minute, which is more than sufficient for monitoring and staleness detection, while avoiding a write on every request.

The throttle uses an in memory map of key identifiers to last update timestamps. This avoids hitting the database just to check whether we need to update the database. The map is small (one entry per active key) and gets cleared naturally when the process restarts.

The Broader Principle

Timing attacks against authentication are one of those vulnerabilities that’s easy to dismiss as theoretical. “Nobody is going to measure nanosecond differences against my API.” Maybe. But the fix is so simple (hash both values, use timingSafeEqual) that there’s no reason not to do it right. Security is about making the correct choice easy, and constant time comparison is the correct choice for any secret comparison.

Source Code

The safeEqual function hashes both inputs to a fixed length before comparison, ensuring constant time evaluation regardless of input size:

function safeEqual(a: string, b: string): boolean {
  const ha = createHash('sha256').update(a).digest();
  const hb = createHash('sha256').update(b).digest();
  return timingSafeEqual(ha, hb);
}

View the full source on GitHub

// share

// subscribe

New posts and updates straight to your inbox. No noise.

cd ..