DEV Community

Gonzalo Salvador Corvalán
Gonzalo Salvador Corvalán

Posted on

From `.env` string arrays to database-backed, permission-scoped API keys

1. The core problem with the original design

The existing system for Unlimited Blades Work streaming backend stores raw key strings in the .env file in a JSON array of strings and checks every incoming key against every value in that array with a plain equality check, presenting two structural problems:

  1. No granularity — any valid key unlocks the entire application.
  2. No lifecycle — revoking a key means editing a file and restarting the process; there's no audit trail, no expiry, and no way to revoke a key instantly.

2. The correct abstraction: Permission tuples

The pattern used by AWS IAM, GitHub fine-grained tokens, Stripe, and Twilio is to model each grant as an explicit (method, route) pair, called a permission tuple or policy entry. Allowing access to specific routes with specific HTTP methods.

ApiKey → 1-to-many → ApiKeyPermission { method, route }
Enter fullscreen mode Exit fullscreen mode

Each row is an atomic, indivisible grant. Your example becomes:

Row method route result
1 GET /resource-1
2 POST /resource-1
3 GET /resource-2
(no row) POST /resource-2 ❌ denied

3. Data model (I'm using Prisma for this project so here is the schema)

enum HttpMethod {
  GET
  POST
  PUT
  PATCH
  DELETE
  HEAD
  OPTIONS
}

model ApiKey {
  id           Int                @id @default(autoincrement())
  name         String             @db.VarChar(100)   // e.g. "Unlimited Blades Work Client"
  key_prefix   String             @db.VarChar(16)    // e.g. "apc_" — plaintext for fast DB lookup
  hash         String             @db.VarChar(255)   // bcrypt hash — the raw key is never stored
  is_active    Boolean            @default(true)
  created_at   DateTime           @default(now())
  updated_at   DateTime           @updatedAt
  expires_at   DateTime?                             // optional TTL; null = never expires
  last_used_at DateTime?                             // updated on every successful auth (fire-and-forget)

  permissions  ApiKeyPermission[]

  @@index([key_prefix], name: "idx_key_prefix")
  @@map("api_keys")
}

model ApiKeyPermission {
  id         Int        @id @default(autoincrement())
  api_key_id Int
  method     HttpMethod
  route      String     @db.VarChar(255)             // exact path e.g. "/api/v2/serve-episode"

  api_key    ApiKey     @relation(fields: [api_key_id], references: [id], onDelete: Cascade)

  @@unique([api_key_id, method, route])              // DB-level constraint: no duplicate grants
  @@index([api_key_id], name: "idx_permission_key")
  @@map("api_key_permissions")
}
Enter fullscreen mode Exit fullscreen mode

Why each field exists

Field Rationale
key_prefix plaintext Narrows DB candidates before bcrypt — see Phase 5
hash only Raw key shown once at creation, never persisted — mirrors GitHub / Stripe / npm token UX
@@unique([api_key_id, method, route]) DB-level guard against accidental duplicate grants
is_active Soft-disable without deleting audit history
expires_at Supports short-lived keys; middleware rejects expired keys without any extra step
last_used_at Enables rotation policy ("revoke keys unused for 90 days")
onDelete: Cascade Deleting a key removes all its permissions atomically

4. The full API Key lifecycle

Phase 1 — Creation (refactored key generation script)

The script needs to become a proper interactive CLI tool. It should:

  1. Accept arguments (or prompt interactively) for:
    • --name — human-readable name for the consuming service
    • --prefix — key prefix (default: apc_)
    • --permissions — one or more METHOD:ROUTE pairs, e.g. GET:/api/v2/serve-episode
    • --expires — optional ISO date string or duration (e.g. 90d)
  2. Generate the random key using randomBytes as today.
  3. Hash it with bcrypt at cost 12.
  4. Open a Prisma DB connection and run a transaction:
    • Insert one row in api_keys.
    • Insert one row per (method, route) pair in api_key_permissions.
  5. Close the DB connection.
  6. Print the raw key once to stdout and exit. The key cannot be recovered after this point.

Example invocation:

npx ts-node scripts/generateApiKey.ts \
  --name "Unlimited Blades Work Client" \
  --prefix "apc_" \
  --permissions "GET:/api/v1/get-resource" "POST:/api/v3/create-resource" \
  --expires "2027-01-01"
Enter fullscreen mode Exit fullscreen mode

Terminal output:

🔑 Generating API Key for "Unlimited Blades Work Client"...

✅ API Key created and stored successfully!

📋 Details:
   Name:        Unlimited Blades Work Client
   DB ID:       7
   Prefix:      apc_
   Expires:     2027-01-01T00:00:00.000Z
   Created:     2026-07-14T10:22:01.000Z

🔐 Permissions granted:
   GET  /api/v1/get-resource
   POST /api/v3/create-resource

⚠️  RAW KEY (copy now — it will NOT be shown again):

   apc_3f7a9c2e1b84d0f6...

💡 The hash is stored in the database. The raw key is not.
Enter fullscreen mode Exit fullscreen mode

Phase 2 — Usage (middleware flow)

Every request passes through the auth middleware. Non of the routes of this app are exposed to the public without an API key. Here is the exact sequence of operations:

Incoming request
      │
      ▼
1. Extract raw key
   - req.headers['x-api-key']
   - req.headers['authorization'] (strip "Bearer ")
   - req.query.apiKey (This is because video players on the client-side can't send headers in the request so we must add it in the URL as a query parameter)

   Missing? → 401 Unauthorized
      │
      ▼

2. Parse prefix from raw key
   - e.g. "apc_3f7a9c2e..." → prefix = "apc_"
      │
      ▼

3. Cache lookup ← O (1), in-memory
   - Key: SHA-256(rawKey)  ← never store the raw key as a cache key
   - Hit? → Go to step 6
   - Miss? → Continue
      │
      ▼

4. DB query — Find candidates by prefix
   - WHERE key_prefix = "apc_" AND is_active = true
     AND (expires_at IS NULL OR expires_at > NOW())
   - Include: permissions[]
   - Result is a small set (usually 1–3 rows)
      │
      ▼

5. bcrypt.compare(rawKey, candidate.hash) for each candidate
   - No match after all candidates? → 403 Forbidden (log the attempt)
   - Match found? → store result in cache with TTL
      │
      ▼

6. Permission check
   - Does matchedKey.permissions contain { method: req.method, route: req.path }?
   - No? → 403 Forbidden (log: "key X attempted unauthorized route")
   - Yes? → continue
      │
      ▼

7. Non-blocking side effects (fire-and-forget, don't await)
   - prisma.apiKey.update({ last_used_at: new Date() })
      │
      ▼

8. Attach to request context
   - req.apiKeyId = matchedKey.id
   - req.apiKeyName = matchedKey.name
      │
      ▼

9. next()
Enter fullscreen mode Exit fullscreen mode

Phase 3 — Caching (full deep-dive)

The problem bcrypt creates

bcrypt at cost factor 12 takes approximately 250–350ms per comparison. Without caching, every single authenticated request burns that time before any application logic runs. This is non-viable in production.

The cache design

Use lru-cache for a single-process app (zero infrastructure), or Redis for a multi-process / multi-instance deployment.

Why I'm using lru-cache. I could make the caching solution service myself with node.js native APIs, adding more complexity to the project, learning how to manually manage cached data, and leaving the app light-weight without extra dependencies but there is a problem with this approach and that is that I'm trying to create a real-world solution and in the real world you don't always create your own solution. Its a problem of balancing light-weight, complexity, and risks (because if you are making your own solutions, most likely you'll unwillingly introduce some bugs if the solution is too complex).

For this app I could use Redis, but it would be a clear overkill as I don't have that many users and recurrent requests. I could also create my own service to manually manage cache, but I would spend too much time on this migration and most likely introduce bugs into the workflow. Hence why I'm going with lru-cache.

What gets cached:

interface CachedApiKey {
    id: number;
    name: string;
    permissions: Array<{ method: string; route: string }>;
}

// Cache: SHA-256(rawKey) → CachedApiKey
Enter fullscreen mode Exit fullscreen mode

Cache configuration:

import { LRUCache } from 'lru-cache';

const apiKeyCache = new LRUCache<string, CachedApiKey>({
    max: 7, // max 7 unique keys in memory at once
    ttl: 5 * 60_000, // 5-minute TTL per entry
    allowStale: false, // expired entries are never served
});
Enter fullscreen mode Exit fullscreen mode

TTL trade-off:
| TTL | Pro | Con |
|---|---|---|
| Short (1–2 min) | Revoked keys stop working quickly | More DB hits under sustained load |
| Medium (5 min) | Good balance — standard industry practice | A revoked key remains valid for up to 5 minutes |
| Long (30+ min) | Minimal DB pressure | Revocation takes too long to propagate |

5 minutes is going to be my election for this app. This is the same TTL used by GitHub's server-to-server token validation and AWS STS session token caching.

Cache invalidation on revocation

When a key is soft-disabled (is_active = false), the cache entry for that key is not immediately evicted. The middleware has no way to map a DB record id back to its SHA-256 cache key without storing the raw key — which is deliberately never stored.

My chosen strategy is to accept the TTL window: a revoked key continues to work for at most the configured TTL (5 minutes) until its cache entry naturally expires. On the next cache miss after expiry, the cold path runs, the DB returns is_active = false, and the key is permanently denied.

This is the same trade-off accepted by GitHub (server-to-server tokens), Stripe (secret key validation), and AWS STS (session token caching). The TTL window is a known, documented, and acceptable behavior — not a security flaw. The 5-minute window is short enough to be operationally safe while eliminating all the complexity of a secondary re-validation step on every cache hit.

Instead of adding more complexity to the middleware I decided to let the API key live for 5 more minutes. There are only 2 apps really that are going to have API keys so any suspicious activity is going to be easy to spot.

The revocation flow is simply:

// In an admin script — no cache interaction needed
await prisma.apiKey.update({
    where: { id: keyId },
    data: { is_active: false },
});
// The key will stop working within the next TTL window (≤ 5 minutes)
Enter fullscreen mode Exit fullscreen mode

Or simply modify the record manually with something like Strapi V5 ;).


Phase 4 — Disabling a Key

Disabling (is_active = false) is a reversible, non-destructive operation. It is used when:

  • A key is suspected compromised but you want to investigate before deleting.
  • A service is temporarily taken offline.
  • A key has expired and you want to retain the audit trail.

What happens when a key is disabled:

  1. An admin script updates is_active = false in the DB.
  2. If the key has a live cache entry, it continues to pass authentication for up to the remaining TTL (at most 5 minutes). This is the accepted TTL-window trade-off described in Phase 3.
  3. Once the cache entry expires, the cold path runs on the next request. The DB query filters is_active = true, so the disabled key is no longer returned as a candidate — bcrypt is never even reached. The request receives 403 Forbidden.
  4. All subsequent requests from that key are denied indefinitely.

Re-enabling: set is_active = true. The key works again on the next request after its cache entry expires. No script regeneration or code change needed.


Phase 5 — Deleting a Key

Hard deletion (DELETE FROM api_keys WHERE id = ?) is irreversible and should only be used when:

  • The key was generated by mistake.
  • The key is confirmed compromised and the audit trail is no longer needed.
  • A consuming service is permanently decommissioned.

What happens on deletion:

  1. The onDelete: Cascade constraint automatically removes all rows in api_key_permissions for that key.
  2. No orphaned permission data remains.
  3. Any cache entry for that key expires naturally within the TTL window. On the next cold-path DB query, the key's row no longer exists, so bcrypt is never reached and the request is denied.
  4. The key cannot be re-enabled. If the service needs access again, a new key must be generated via the script.

Since the raw key is never stored, there is no way to recover it after creation. If a key is deleted, the consuming service must be updated with a newly generated key.


5. Updated Middleware Pseudocode

import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
import bcrypt from 'bcrypt';
import { prisma } from '../database/prisma';
import type { Request, Response, NextFunction, RequestHandler } from 'express';

interface CachedApiKey {
    id: number;
    name: string;
    permissions: Array<{ method: string; route: string }>;
}

const cache = new LRUCache<string, CachedApiKey>({
    max: 7,
    ttl: 5 * 60_000,
});

const sha256 = (value: string) => createHash('sha256').update(value).digest('hex');

export const authenticateApiKey = (): RequestHandler => {
    return async (req: Request, res: Response, next: NextFunction) => {
        const rawKey =
            (req.headers['x-api-key'] as string) ||
            req.headers['authorization']?.replace('Bearer ', '') ||
            (req.query.apiKey as string);

        if (!rawKey) {
            return res.status(401).json({ error: 'API key required' });
        }

        const cacheKey = sha256(rawKey);
        let resolved: CachedApiKey | undefined = cache.get(cacheKey);

        if (resolved) {
            // Cache hit — trust the cached result for the remaining TTL.
            // If the key was revoked in the DB, this entry will stop being served
            // once the TTL expires and the cold path re-runs (≤ 5 minutes).
        } else {
            // Cold path: prefix lookup + bcrypt
            const prefix = rawKey.split('_')[0] + '_'; // "apc_"
            const candidates = await prisma.apiKey.findMany({
                where: {
                    key_prefix: prefix,
                    is_active: true,
                    OR: [{ expires_at: null }, { expires_at: { gt: new Date() } }],
                },
                include: { permissions: true },
            });

            let matched = null;
            for (const candidate of candidates) {
                if (await bcrypt.compare(rawKey, candidate.hash)) {
                    matched = candidate;
                    break;
                }
            }

            if (!matched) {
                // Log the attempt without exposing the key
                logData({
                    title: `[auth] Invalid API key attempt`,
                    data: { prefix },
                    layer: 'auth_middleware',
                    type: 'warn',
                    addSeparatorAfter: true,
                    addSpaceAfter: true,
                    timeStamp: true,
                });
                return res.status(403).json({ error: 'Invalid API key' });
            }

            resolved = {
                id: matched.id,
                name: matched.name,
                permissions: matched.permissions.map(p => ({
                    method: p.method,
                    route: p.route,
                })),
            };
            cache.set(cacheKey, resolved);
        }

        // Permission check
        const hasPermission = resolved.permissions.some(p => p.method === req.method && p.route === req.path);

        if (!hasPermission) {
            console.warn(`[auth] Key "${resolved.name}" denied: ${req.method} ${req.path}`);
            return res.status(403).json({ error: 'Access denied for this route' });
        }

        // Non-blocking audit update
        prisma.apiKey.update({ where: { id: resolved.id }, data: { last_used_at: new Date() } }).catch(() => {});

        req.apiKeyId = resolved.id;
        req.apiKeyName = resolved.name;
        next();
    };
};
Enter fullscreen mode Exit fullscreen mode

6. Refactored script for generating a new API key

The script needs to accept CLI arguments and connect to the database. The structure:

import { PrismaClient } from '@prisma/client';
import { generateApiKeyWithPrefix } from '../src/services/apiKey.service';

interface Permission {
  method: string;  // "GET", "POST", etc.
  route: string;   // "/api/v1/get-resource"
}

const parsePermissions = (args: string[]): Permission[] => {
  // Parse "METHOD:ROUTE" pairs, e.g. ["GET:/api/v1/get-resource", "POST:/api/v3/create-resource"]
  return args.map((arg) => {
    const colonIndex = arg.indexOf(':');

    if (colonIndex === -1) throw new Error(`Invalid permission format: "${arg}". Expected "METHOD:ROUTE"`);

    return {
      method: arg.slice(0, colonIndex).toUpperCase(),
      route: arg.slice(colonIndex + 1),
    };
  });
};

const main = async () => {
  const args = process.argv.slice(2);

  // Parse --name, --prefix, --permissions, --expires from args
  const name = /* parse --name */;
  const prefix = /* parse --prefix, default "apc_" */;
  const permissions = parsePermissions(/* parse --permissions list */);
  const expiresAt = /* parse --expires, default null */;

  if (!name) throw new Error('--name is required');
  if (permissions.length === 0) throw new Error('At least one --permissions entry is required');

  const prisma = new PrismaClient();

  try {
    const apiKey = await generateApiKeyWithPrefix(prefix, 32);

    // Atomic transaction: insert key + all permissions together
    await prisma.$transaction([
      prisma.apiKey.create({
        data: {
          name,
          key_prefix: prefix,
          hash: apiKey.hash,
          expires_at: expiresAt,
          permissions: {
            create: permissions.map((p) => ({
              method: p.method as HttpMethod,
              route: p.route,
            })),
          },
        },
      }),
    ]);

    // Print the raw key — only time it will ever be visible
    console.log(`\n✅ API Key created for "${name}"\n`);
    console.log(`   Permissions:`);
    permissions.forEach((p) => console.log(`   ${p.method.padEnd(7)} ${p.route}`));
    console.log(`\n⚠️  RAW KEY (copy now):\n`);
    console.log(`   ${apiKey.key}\n`);
  } finally {
    await prisma.$disconnect();
  }
};

main().catch((e) => { console.error(e); process.exit(1); });
Enter fullscreen mode Exit fullscreen mode

[!NOTE]
Using a $transaction for both inserts ensures that if the permissions insert fails for any reason (e.g. a duplicate route entry), the api_keys row is also rolled back so that I never end up with a key in the DB that has no permissions.


7. Route matching: Exact vs. Wildcard

All current routes are exact paths, so exact string matching (p.route === req.path) is enogh. If later I add parameterized routes (e.g., /api/v2/resources/:id), I'll make the switch to path-to-regexp — the same library Express.js uses internally — without needing to change the DB schema. The route column would store patterns like /api/v2/resources/:id and the middleware would match against them.


8. Compromise blast radius analysis

Scenario Without this system With this system
Key for Service A is leaked Attacker can hit all routes with any method Attacker can only hit the 2–3 granted routes
GET-only service key is leaked Attacker can POST to webhooks, triggering side effects Attacker can only GET — POST returns 403
Key is revoked Requires editing .env and restarting the process Set is_active = false → denied on next request
Key expires No concept of expiry expires_at enforced by middleware automatically
Attacker brute-forces a valid key Full access Scoped to minimum declared permissions
Developer hardcodes a key in source Full exposure if repo is leaked Exposure limited to that service's minimal surface

9. What I'll Keep from the current system

  • bcrypt hashing via bcrypt.hash() / bcrypt.compare() — correct algorithm, keep cost factor 12
  • ✅ The apc_ prefix convention — formalize it as key_prefix in the schema
  • ✅ The generateApiKeyWithPrefix() service function — reuse as-is
  • ✅ The x-api-key / Authorization / req.query.apiKey extraction logic
  • ✅ The ApiKey interface

10. Conclusion

This migration is probably one of the most meaningful security changes I have made in this backend app so far.

I started with a simple .env-based API key list because it was fast to ship, but as the project grew, that model became too risky: one leaked key meant broad access, and revocation depended on manual file edits and restarts. Moving to database-backed keys with permission tuples forced me to treat access as something explicit and intentional, not implicit and global.

With this new approach keys can be scoped, disabled, expired, audited, and deleted with clear behavior. The cache introduces a small revocation window, but that trade-off is documented, controlled, and acceptable for the current scale. It is a conscious engineering decision, not an accident.

The goal is not to build a perfect fortress on day one, but to build a system that is safer today and still easy to evolve tomorrow. With this foundation in place, I can now add rotation workflows, richer admin tooling, and pattern-based route matching without tearing everything down again.

This is the point where API keys in Private Cloud Backend stop being shared secrets and start being real security boundaries.

Thanks for reading

Comment "REST" to get some rest after reading all that, that way I'll know that you're actually a human.

If this topic interests you, read the full post with nice background music at https://corvalangonzalo.com/blog/securing-apis and share your thoughts in the comments section here so we can discuss API security decisions, trade-offs, and better approaches together.

Top comments (0)