Auth operation latency benchmarks: what's fast, what's slow, and why

Authentication operations span four orders of magnitude in latency: a JWT signature verification takes 0.1ms while bcrypt at cost factor 12 takes 250ms. If you are sizing auth infrastructure or troubleshooting why your login endpoint is slow, understanding the cost of each operation is essential. These benchmarks were measured on a modern server CPU (Intel Xeon Cascade Lake, 2.5 GHz) using Node.js 18 and Python 3.10.

Password hashing: bcrypt vs Argon2

Password hashing is intentionally slow — the whole point is to make brute-force attacks expensive. The cost parameter controls how slow. The challenge is calibrating it so that legitimate logins are fast enough that users tolerate it while still being slow enough to deter attackers.

// bcrypt cost factor benchmark (Node.js bcrypt library)
// Measured on Intel Xeon @ 2.5GHz, single-threaded

// Cost factor 10: ~65ms hash time
// Cost factor 11: ~130ms
// Cost factor 12: ~260ms  <-- common production recommendation
// Cost factor 13: ~520ms
// Cost factor 14: ~1040ms  <-- too slow for most web apps

import bcrypt from 'bcrypt';
import { performance } from 'perf_hooks';

async function benchmarkBcrypt(costFactor) {
  const start = performance.now();
  await bcrypt.hash('correcthorsebatterystaple', costFactor);
  return performance.now() - start;
}

// The rule of thumb: tune bcrypt so hash takes 100-300ms on your auth server.
// That means adjusting cost as servers get faster.
// A cost factor appropriate in 2015 may be too fast on 2022 hardware.
# Argon2id benchmark (Python argon2-cffi)
# Argon2id is the winner of the Password Hashing Competition
# and is NIST's current recommendation over bcrypt

from argon2 import PasswordHasher
import time

# Default parameters (RFC 9106 recommendation 1 for high-memory scenarios)
# memory_cost=65536 (64MB), time_cost=3, parallelism=4
ph = PasswordHasher(
    memory_cost=65536,
    time_cost=3,
    parallelism=4
)

start = time.perf_counter()
hash = ph.hash("correcthorsebatterystaple")
elapsed = time.perf_counter() - start
print(f"Argon2id (64MB, 3 iterations): {elapsed*1000:.1f}ms")
# Output: Argon2id (64MB, 3 iterations): ~280ms on same hardware

# More conservative parameters for memory-constrained environments:
# memory_cost=19456 (19MB), time_cost=2
# Output: ~80ms

Argon2id is preferable to bcrypt for new systems because it is memory-hard (making GPU-based attacks more expensive) and provides better parallelism control. The memory cost is the key advantage: a GPU with thousands of cores is bottlenecked by VRAM when memory_cost is high, while bcrypt can be parallelized cheaply across many cores.

Hashing happens on your auth server's CPU. At cost factor 12 (250ms/hash), a single core can handle 4 login requests per second. For 1000 concurrent logins per second, you need 250 cores — or you need a dedicated password hashing service that can be horizontally scaled independently from your main auth logic. Plan your capacity accordingly.

JWT signing: ES256 vs RS256

// JWT signing benchmark (jose library, Node.js 18)
import { SignJWT, importPKCS8 } from 'jose';
import { performance } from 'perf_hooks';

async function benchmarkSigning() {
  const ecKey = await importPKCS8(EC_PRIVATE_KEY, 'ES256');
  const rsaKey = await importPKCS8(RSA_PRIVATE_KEY_2048, 'RS256');

  // ES256 (P-256 ECDSA)
  const ecStart = performance.now();
  for (let i = 0; i < 1000; i++) {
    await new SignJWT({ sub: 'user_123', org: 'org_456' })
      .setProtectedHeader({ alg: 'ES256' })
      .setIssuedAt()
      .setExpirationTime('1h')
      .sign(ecKey);
  }
  const ecMs = (performance.now() - ecStart) / 1000;
  console.log(`ES256: ${ecMs.toFixed(2)}ms/op`);  // ~0.12ms

  // RS256 (RSA-2048)
  const rsaStart = performance.now();
  for (let i = 0; i < 1000; i++) {
    await new SignJWT({ sub: 'user_123', org: 'org_456' })
      .setProtectedHeader({ alg: 'RS256' })
      .setIssuedAt()
      .setExpirationTime('1h')
      .sign(rsaKey);
  }
  const rsaMs = (performance.now() - rsaStart) / 1000;
  console.log(`RS256: ${rsaMs.toFixed(2)}ms/op`);  // ~1.4ms

  // ES256 verification (public key)
  // ~0.10ms per operation
  // RS256 verification:
  // ~0.08ms per operation (public key exponent op is fast)
}
// Summary: signing ES256 is ~12x faster than RS256.
// Verification is similar. For high-throughput auth servers, ES256 is the correct default.

PostgreSQL session lookup query plans

Session lookups happen on every API request that uses server-side sessions. The query must use an index — a sequential scan on a sessions table with millions of rows will kill your database.

-- Session table index setup
CREATE TABLE sessions (
  id          UUID PRIMARY KEY,  -- UUID indexed by default on primary key
  user_id     UUID NOT NULL,
  data        JSONB,
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  expires_at  TIMESTAMPTZ NOT NULL,
  last_seen   TIMESTAMPTZ
);

-- Index for expiry-aware lookups (partial index on non-expired sessions)
CREATE INDEX idx_sessions_active
  ON sessions (id)
  WHERE expires_at > NOW();  -- partial index: only non-expired rows

-- Index for user's sessions (profile page, "active sessions" management)
CREATE INDEX idx_sessions_user ON sessions (user_id, expires_at DESC);

-- EXPLAIN ANALYZE output for primary session lookup:
-- SELECT * FROM sessions WHERE id = '...' AND expires_at > NOW()
-- Index Scan using sessions_pkey on sessions  (cost=0.56..8.58 rows=1 width=512)
-- Index Cond: (id = '...')
-- Filter: (expires_at > now())
-- Planning Time: 0.1 ms
-- Execution Time: 0.08 ms  <-- sub-millisecond with hot index

-- Without the index (UUID in JSONB or wrong column type):
-- Seq Scan on sessions  (cost=0.00..85420.00 rows=1 width=512)
-- Execution Time: 180ms  <-- catastrophic at scale

TOTP verification speed

TOTP verification is fast — it is just HMAC-SHA1 computation with a time window check. The cost is effectively zero compared to password hashing. The overhead is the database lookup to retrieve the TOTP secret and check the used-code table to prevent replay.

// TOTP verification with replay protection
// Benchmark: ~0.3ms total including two DB queries with indexes

import { authenticator } from 'otplib';

async function verifyTOTP(userId, code) {
  // 1. Fetch TOTP secret (should be cached in Redis after first use)
  const secret = await getTOTPSecret(userId);  // ~0.5ms with DB, ~0.05ms with cache

  // 2. Check replay: was this code used in the last 30s?
  const codeKey = `${userId}:${code}`;
  const used = await redis.exists(`totp_used:${codeKey}`);
  if (used) throw new Error('Code already used');

  // 3. Verify (allows ±1 window = 90 second total validity)
  authenticator.options = { window: 1 };
  const valid = authenticator.verify({ token: code, secret });

  if (valid) {
    // 4. Mark as used for replay protection
    await redis.setex(`totp_used:${codeKey}`, 90, '1');
  }

  return valid;
}
// Total TOTP path: ~1-2ms with cached secret, ~2-4ms with DB lookup

Putting it together: login endpoint latency budget

A full password login with TOTP MFA has this latency budget on a well-tuned system:

  • User lookup by email (indexed): 0.5ms
  • bcrypt verify (cost 12): 260ms
  • TOTP verify (cached secret): 1ms
  • Session creation (Redis): 0.5ms
  • JWT signing (ES256): 0.1ms
  • Audit log write (async, not on critical path): 0ms
  • Total: ~262ms

The login endpoint is unavoidably dominated by password hashing. That is by design. Optimize everything else aggressively (indexes, connection pooling, Redis for session writes), but accept that a 250ms floor for password authentication is correct and intentional.

← Back to blog Try Bastionary free →