Part of the MediaBridge series.

The Design Premise

Most access control systems respond to violations with a 403. You tried to access something you should not have - here is a polite rejection. Come back when you have the right permissions.

MediaBridge takes a different position. Certain violation types are not mistakes. A user navigating to a URL they are not supposed to reach is an accident. A user constructing a request with a path outside their assigned root prefix is not. The system treats the latter as an active intrusion attempt and terminates the session immediately, rather than returning a 403 and letting the session continue.

Path Confinement

Every user in MediaBridge is assigned to a root path within a bucket. A user assigned to projects/marketing/ can list files under that prefix, upload to it, and search within it. They cannot reach projects/engineering/ or browse up to projects/ or the bucket root.

This confinement is enforced on every single operation that touches a path: file listing, URL fetch, upload, delete, and search. The check is not centralized - it runs at each operation. If the constructed full path does not start with the user’s root path, the session ends.

On file listing:

const fullPrefix = rootPath ? (prefix ? `${rootPath}${prefix}` : rootPath) : prefix;

if (rootPath && !fullPrefix.startsWith(rootPath)) {
  await forceLogout(user.userId, reply);
  return;
}

On batch URL fetch, the same check applies across all keys in the request:

if (rootPath && keys.some(k => !k.startsWith(rootPath))) {
  await forceLogout(user.userId, reply);
  return;
}

On upload, the path is validated before any presigned URL is generated. On delete, the s3Key is validated before the S3 call is made. The pattern is identical everywhere: construct the full path, check it starts with root, force logout if not.

Force Logout Mechanism

Force logout is not a redirect. It is a database record.

async function forceLogout(userId: string, reply: FastifyReply) {
  await sql`
    INSERT INTO force_logout (user_id, logged_out_at)
    VALUES (${userId}, NOW())
    ON CONFLICT (user_id) DO UPDATE SET logged_out_at = NOW()
  `;
  reply.status(401).send({ code: 'FORCE_LOGGED_OUT', message: 'Access violation detected' });
}

The force_logout table has one row per user. When a violation is detected, the current timestamp is written to that row. The session gets a 401 FORCE_LOGGED_OUT response.

Every subsequent request from that user - on any device, with any token - hits this check in the auth middleware:

const [forceLogout] = await sql`
  SELECT logged_out_at FROM force_logout WHERE user_id = ${payload.userId}
`;

if (forceLogout) {
  const tokenIat = (payload as any).iat * 1000;
  if (new Date(forceLogout.logged_out_at).getTime() > tokenIat) {
    return reply.status(401).send({ code: 'FORCE_LOGGED_OUT', message: 'Session terminated' });
  }
}

The comparison is against the token’s iat (issued-at). Tokens issued before the force logout timestamp are invalid. Tokens issued after it - from a fresh login - are valid. This means forcing someone out does not permanently lock them. It invalidates all current sessions across all devices. They can log in again cleanly.

The same check runs in the WebSocket search auth path, and in the /auth/me endpoint used on every page load.

Filename Validation

Upload filenames are validated independently of path confinement. A filename containing .. or / is rejected immediately:

if (!file.filename || file.filename.includes('..') || file.filename.includes('/')) {
  return reply.status(400).send({ code: 'VALIDATION_ERROR', message: `Invalid filename: ${file.filename}` });
}

This prevents a client from encoding traversal in the filename itself. The path and filename validations are separate layers.

Timing-Safe Webhook Authentication

Two external callers hit the MediaBridge backend: the Lambda that sends cache eviction webhooks, and the Lambda pair that drives archive restore. Both are authenticated with shared secrets.

Comparing secrets with a regular string comparison (===) is vulnerable to timing attacks. The comparison short-circuits on the first differing byte, which leaks information about how many leading bytes of the secret an attacker has correct.

MediaBridge uses crypto.timingSafeEqual for all webhook secret comparisons:

export function timingSafeCompare(a: string, b: string): boolean {
  try {
    return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
  } catch {
    return false;
  }
}

The try/catch handles the case where the two buffers have different lengths - timingSafeEqual throws rather than returning false in that case.

This is used in both the cache eviction route:

if (!expectedSecret || !secret || !timingSafeCompare(String(secret), expectedSecret)) {
  return reply.status(401).send({ code: 'UNAUTHORIZED', message: 'Invalid eviction secret' });
}

And the restore webhook route:

if (!secret || !expected || !timingSafeCompare(String(secret), expected)) {
  reply.status(401).send({ code: 'UNAUTHORIZED', message: 'Invalid webhook secret' });
  return false;
}

Credentials Encrypted at Rest

Each bucket’s IAM access key and secret are stored encrypted in the database using AES-256-CBC. A single ENCRYPTION_KEY env var controls this - it never leaves the backend process.

export function encrypt(text: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv('aes-256-cbc', KEY, iv);
  const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
  return iv.toString('hex') + ':' + encrypted.toString('hex');
}

A fresh IV is generated per encryption. The stored value is iv:ciphertext. Decryption requires the same key and the stored IV. Even if the database is compromised, the credentials are not readable without the key.

Refresh Token Rotation

Refresh tokens are never stored in plaintext. The raw token is a 64-byte random value generated by crypto.randomBytes. What goes into the database is a SHA-256 hash:

export function hashToken(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex');
}

On every refresh, the used token hash is moved to used_refresh_tokens before a new token is issued. If the same refresh token is presented twice, the second use is rejected:

const [used] = await sql`
  SELECT token_hash FROM used_refresh_tokens WHERE token_hash = ${tokenHash}
`;
if (used) {
  return reply.status(401).send({ code: 'REFRESH_TOKEN_USED', message: 'Refresh token already used' });
}

A detected reuse triggers an immediate redirect to login on the frontend. This is a signal that the token may have been stolen - the response is the same as a force logout from the client’s perspective.

Delete Permission

Delete is off by default. Each user-bucket assignment has an allow_delete flag that is false unless an admin explicitly enables it. The check is bucket-specific - having delete permission on one bucket does not grant it on another:

if (!user.isAdmin && !bucket.allow_delete) {
  return reply.status(403).send({
    code: 'DELETE_NOT_ALLOWED',
    message: 'You are not allowed to delete files in this bucket. Contact your admin.'
  });
}

Admins bypass the delete permission check.

Rate Limiting

Auth endpoints are rate-limited to 5 requests per minute per IP. The global rate limit is 60 requests per minute. Lambda webhook endpoints (restore, cache eviction, thumbnail generation) are excluded from rate limiting since they are machine callers with known traffic patterns:

skip: (req) => req.url?.startsWith('/restore/') || req.url?.startsWith('/cache/') || req.url?.startsWith('/thumbnails/')

Next: JWT Refresh Queue Pattern