n4nAI

API integration

Guide

Express middleware for LLM API key authentication

A practical guide to building Express.js middleware for LLM API key authentication: scoped keys, rotation, rate limits, and safe proxying to LLM gateways.

n4n Team4 min read919 words

Audio narration

Coming soon — every post will get a voice note here.

Most LLM backends bolt auth on as an afterthought, then wonder why keys leak. A solid express.js middleware api key authentication layer lets you enforce scoping, rotation, and rate limits at the edge before any token ever hits your model route.

Why terminate API keys at your own middleware

When your Express service sits between clients and an LLM provider, you control the trust boundary. If you pass raw user keys downstream, you lose the ability to revoke, scope, or meter them centrally. Terminating auth in middleware means your route handlers never see secrets—they see a verified principal.

This matters for LLM apps because a single leaked key can drain a provider quota or invoke expensive models. The express.js middleware api key authentication pattern pushes that risk upstream of your business logic, so a compromised client credential never reaches the upstream API with full freedom.

Define your key model and storage

Before writing code, decide what a key represents. Is it a user, an org, or a device? I recommend mapping each key to a tuple: (id, owner_id, scopes, rate_limit, expires_at).

Store keys in a fast KV store (Redis or DynamoDB). Do not store them in a SQL table without caching—you will hit that table on every request, and LLM endpoints are latency-sensitive.

Use hashed keys, not plaintext

Generate keys as prefix.random(32) and store only a SHA-256 hash of the random part. The prefix (sk_live_) is cosmetic and helps clients identify the key type. On lookup, hash the presented key and query by hash. Never log the raw key.

{
  "key_hash": "a1b2c3...",
  "owner_id": "org_123",
  "scopes": ["chat:gpt-4o", "embed:text"],
  "rate_limit": 100,
  "expires_at": 1735689600
}

Build the base middleware

A minimal middleware extracts the key, validates it, and attaches the principal to req. Keep it synchronous where possible; do async lookup only against cache.

import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';

const KEY_PREFIX = 'sk_live_';

declare global {
  namespace Express {
    interface Request {
      principal?: KeyRecord;
    }
  }
}

export async function apiKeyAuth(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const header = req.get('authorization');
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'missing_api_key' });
  }
  const raw = header.slice(7);
  if (!raw.startsWith(KEY_PREFIX)) {
    return res.status(401).json({ error: 'malformed_api_key' });
  }
  const hash = crypto.createHash('sha256').update(raw).digest('hex');
  const record = await keyStore.get(hash);
  if (!record || record.expires_at < Date.now() / 1000) {
    return res.status(403).json({ error: 'invalid_or_expired_key' });
  }
  req.principal = record;
  next();
}

Centralize error responses

Don’t scatter 401s across handlers. Return a consistent JSON shape and map internal errors to generic messages. Exposing “key not found” vs “key expired” can help an attacker enumerate valid prefixes. Use the same error envelope across middleware.

Scope keys to routes and models

LLM routes often differ by capability: chat completions, embeddings, fine-tune jobs. Your express.js middleware api key authentication should enforce scopes before the route runs.

export function requireScope(scope: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const principal = req.principal;
    if (!principal?.scopes.includes(scope)) {
      return res.status(403).json({ error: 'insufficient_scope' });
    }
    next();
  };
}

// Usage:
app.post('/v1/chat', apiKeyAuth, requireScope('chat:gpt-4o'), chatHandler);

Tradeoff: fine-grained scopes add middleware chains but cut blast radius when a key leaks. Coarse scopes are simpler but let a leaked key call any model you expose.

Add rotation and revocation

Support at least two active keys per owner. Issue a new key, set a short overlap window, then expire the old. Revocation is just deleting the hash from the store.

# Issue new key (server-side script)
curl -X POST https://your-api.internal/keys \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"owner_id":"org_123","scopes":["chat:gpt-4o"]}'

Pitfall: if you cache key records in memory, revocation latency equals cache TTL. Use Redis with sub-second invalidation or a versioned key check. Clients should be instructed to handle 403 by re-fetching from your key service.

Rate limiting and quota enforcement

Authentication without rate limiting is half-measures. Bind a token bucket to principal.id. Use a shared counter in Redis so multiple Express instances agree.

import { RateLimit } from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

const limiter = RateLimit({
  store: new RedisStore({ client: redis }),
  windowMs: 60_000,
  max: (req) => req.principal?.rate_limit ?? 10,
  keyGenerator: (req) => req.principal?.id ?? req.ip,
});

app.use('/v1/', apiKeyAuth, limiter);

Note: express-rate-limit v6+ supports dynamic max. If you run older versions, wrap with your own middleware. Rate limits should reflect model cost—embedding calls can be 100x cheaper than GPT-4 class chat, so scope limits per model family.

Proxying to an LLM gateway

Once the key is verified, you often forward the request to a provider or gateway. Never forward the client’s key unless the upstream explicitly requires it. Instead, inject your own server-side credential and pass the verified principal as a header for logging.

If you proxy to a gateway such as n4n.ai, which exposes a single OpenAI-compatible endpoint across 240+ models with automatic fallback on provider degradation, you keep your user keys out of the upstream request and rely on per-token metering there. Your middleware still owns auth; the gateway owns model routing.

app.post('/v1/chat', apiKeyAuth, requireScope('chat:gpt-4o'), async (req, res) => {
  const upstream = await fetch('https://api.n4n.ai/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.UPSTREAM_KEY}`,
      'Content-Type': 'application/json',
      'X-Owner-Id': req.principal!.owner_id,
    },
    body: JSON.stringify(req.body),
  });
  const data = await upstream.json();
  res.status(upstream.status).json(data);
});

This pattern also lets you honor client routing directives locally—e.g., reject a model not in scope—before spending an upstream call.

Testing the middleware

Your express.js middleware api key authentication tests should cover expired keys, missing scopes, and rate limit triggers. Use supertest against your app instance.

import request from 'supertest';
import app from '../app';

test('rejects missing key', async () => {
  const res = await request(app).post('/v1/chat').send({});
  expect(res.status).toBe(401);
});

test('allows scoped key', async () => {
  const res = await request(app)
    .post('/v1/chat')
    .set('Authorization', `Bearer sk_live_${validKey}`)
    .send({ model: 'gpt-4o' });
  expect(res.status).not.toBe(403);
});

Integration tests catch scope regressions when you add new routes. Run them in CI with a local Redis container.

Deployment considerations

Place apiKeyAuth before express.json() for sensitive routes. Parsing a 2 MB request body from an unauthorized client is a cheap DoS. Use a small body limit after auth:

app.post('/v1/chat', apiKeyAuth, express.json({ limit: '100kb' }), chatHandler);

Set app.set('trust proxy', 1) if behind a load balancer, or IP-based limits and logging lie. Rotate your UPSTREAM_KEY on a schedule independent of client keys.

Common pitfalls and tradeoffs

Logging keys: Never log the raw Authorization header. Log key_hash truncated to first 8 chars for debuggability.

Timing attacks: Use constant-time comparison for hash lookup if you compare in memory. Most KV stores handle this by index lookup, but don’t string-compare hashes in JS.

Over-centralizing: Packing auth, rate limit, scope, and billing into one mega-middleware tempts you to write a 200-line function. Split them; each concern fails independently and is testable in isolation.

Key prefix collisions: Random 32 bytes is plenty, but skip the prefix in the hash to avoid metadata leakage about key type from the stored record.

Cold start latency: Async key lookup on every request adds ~1–2 ms with Redis. Accept it; the alternative is a breach or a broken revocation story.

Scope creep: Don’t grant * scope by default. LLM APIs expose expensive operations; explicit scopes force you to think about cost at the edge.

The express.js middleware api key authentication pattern is boring on purpose. It moves secrets off the hot path, gives you central revocation, and makes your LLM routes predictable. Build it once, then forget it—until a key leaks and you rotate in seconds.

Tagsexpressjsmiddlewareauthenticationapi-keys

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →