惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
T
Threatpost
P
Palo Alto Networks Blog
NISL@THU
NISL@THU
O
OpenAI News
Project Zero
Project Zero
G
GRAHAM CLULEY
P
Privacy International News Feed
A
Arctic Wolf
Microsoft Azure Blog
Microsoft Azure Blog
H
Help Net Security
M
MIT News - Artificial intelligence
T
Threat Research - Cisco Blogs
S
Security @ Cisco Blogs
Google DeepMind News
Google DeepMind News
B
Blog RSS Feed
D
Docker
aimingoo的专栏
aimingoo的专栏
博客园 - 【当耐特】
N
Netflix TechBlog - Medium
云风的 BLOG
云风的 BLOG
雷峰网
雷峰网
W
WeLiveSecurity
P
Proofpoint News Feed
腾讯CDC
Cloudbric
Cloudbric
S
Secure Thoughts
C
Check Point Blog
博客园 - Franky
T
The Exploit Database - CXSecurity.com
T
Troy Hunt's Blog
GbyAI
GbyAI
Security Archives - TechRepublic
Security Archives - TechRepublic
Application and Cybersecurity Blog
Application and Cybersecurity Blog
月光博客
月光博客
C
Cyber Attacks, Cyber Crime and Cyber Security
I
Intezer
TaoSecurity Blog
TaoSecurity Blog
L
Lohrmann on Cybersecurity
V
Visual Studio Blog
F
Fortinet All Blogs
博客园 - 叶小钗
C
CXSECURITY Database RSS Feed - CXSecurity.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Recorded Future
Recorded Future
C
Cisco Blogs
博客园 - 司徒正美
Stack Overflow Blog
Stack Overflow Blog
Y
Y Combinator Blog
Apple Machine Learning Research
Apple Machine Learning Research

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
WebSocket Authentication and Authorization: Securing Real-Time Connections
Layer 2: Authorize per-message, not per-connection · 2026-06-24 · via The Practical Developer

A WebSocket connection is not an HTTP request. It lives for minutes or hours, not milliseconds. It outlasts page navigations, token refreshes, and deployments. And if you authenticate it the same way you authenticate a REST endpoint, you are almost certainly leaving a window open for stale or revoked credentials to keep pumping data through your server.

This is the WebSocket authentication trap: the JWT was valid when the handshake happened, the user was authorized to subscribe to the orders channel when they connected, but ten minutes later their session was revoked, their permissions changed, or their token expired, and your server is still piping real-time order updates into a socket that should have been closed.

Fix this in three layers: authenticate the connection, authorize every message or subscription, and re-validate credentials periodically. Here is the pattern.

Layer 1: Authenticate the handshake

WebSocket connections start as HTTP Upgrade requests. That means the Authorization header, cookies, and query parameters are all available during the handshake. This is your only chance to assert that the client is who they claim to be, before the connection upgrades to the WebSocket protocol.

The problem with query parameters is that they end up in server logs, CDN caches, and browser history. Sending a JWT as ?token=... in the URL leaks the token to every layer of infrastructure between client and server. Use the Authorization header instead.

// client-side: attach token to the handshake
const token = await getAccessToken(); // from your auth library

const ws = new WebSocket('wss://api.example.com/ws', [], {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

Server-side, you need to intercept the Upgrade event and validate the token before the connection switches to the WebSocket protocol.

// server.ts using the `ws` library with Node.js
import { WebSocketServer } from 'ws';
import { verifyToken } from './auth';

const wss = new WebSocketServer({ noServer: true });

const server = http.createServer((req, res) => {
  // Normal HTTP traffic goes here
  res.writeHead(426, { 'Content-Type': 'text/plain' });
  res.end('Upgrade required');
});

server.on('upgrade', (req, socket, head) => {
  const authHeader = req.headers['authorization'];
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  const token = authHeader.slice(7);

  try {
    const payload = verifyToken(token);
    // Attach the authenticated user to the request so the WebSocketServer
    // can access it when the 'connection' event fires.
    (req as any).user = payload;
    wss.handleUpgrade(req, socket, head, (ws) => {
      wss.emit('connection', ws, req);
    });
  } catch (err) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
  }
});

wss.on('connection', (ws, req) => {
  const user = (req as any).user;
  console.log(`User ${user.id} connected`);

  ws.send(JSON.stringify({ type: 'connected', userId: user.id }));
});

This pattern validates the token before the 101 Switching Protocols response is sent. If the token is expired or malformed, the client gets a standard HTTP error that their WebSocket API surfaces as an onerror event. No connection is established, no resources are allocated on the server, and no data flows.

The noServer: true option on WebSocketServer is critical: it tells ws not to start its own HTTP server, because you are handling the Upgrade yourself. Without it, the library’s built-in Upgrade handler runs and your authentication code is never called.

If your app uses session cookies instead of Bearer tokens, you can read the cookie from req.headers.cookie during the Upgrade. The principle is identical: parse the cookie, validate the session, and either allow the Upgrade or reject it. Cookies have the advantage of not requiring the client-side JavaScript to manage token storage. They have the disadvantage that WebSocket connections from contexts without cookie access (like service workers or some mobile WebSocket libraries) cannot authenticate.

Always support both Authorization header and cookies, but document the precedence: header first, cookie fallback. This gives you the widest client compatibility without sacrificing security.

Authenticating the handshake tells you who is connected. It does not tell you what they are allowed to do. A user who connects with a valid session might try to subscribe to a channel they should not have access to, or send a mutation message to a resource they do not own.

Do not authorize once at connection time and then trust the connection for its entire lifetime. Every message that crosses the wire is a fresh authorization boundary.

// Message types and authorization rules
type Message = {
  type: 'subscribe' | 'unsubscribe' | 'send' | 'ping';
  channel?: string;
  payload?: unknown;
};

const CHANNEL_PERMISSIONS: Record<string, string[]> = {
  'orders:user': ['user:read_own_orders'],
  'orders:admin': ['admin:read_all_orders'],
  'notifications': ['user:read_notifications'],
};

function authorize(user: User, channel: string): boolean {
  const required = CHANNEL_PERMISSIONS[channel];
  if (!required) return false;
  return required.some((perm) => user.permissions.includes(perm));
}

wss.on('connection', (ws, req) => {
  const user = (req as any).user;
  const subscriptions = new Set<string>();

  ws.on('message', (raw) => {
    let msg: Message;
    try {
      msg = JSON.parse(raw.toString());
    } catch {
      ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
      return;
    }

    switch (msg.type) {
      case 'subscribe':
        if (!msg.channel) break;
        if (!authorize(user, msg.channel)) {
          ws.send(JSON.stringify({
            type: 'error',
            message: `not authorized for channel: ${msg.channel}`,
          }));
          break;
        }
        subscriptions.add(msg.channel);
        ws.send(JSON.stringify({
          type: 'subscribed',
          channel: msg.channel,
        }));
        break;

      case 'unsubscribe':
        if (!msg.channel) break;
        subscriptions.delete(msg.channel);
        break;

      case 'send':
        // Re-validate for every action message
        if (!authorize(user, msg.channel || '')) {
          ws.send(JSON.stringify({ type: 'error', message: 'not authorized' }));
          break;
        }
        // Handle the message...
        break;

      case 'ping':
        ws.send(JSON.stringify({ type: 'pong' }));
        break;
    }
  });

  // Clean up server-side subscription tracking on disconnect
  ws.on('close', () => {
    subscriptions.clear();
  });
});

The two-line authorize function is deliberately simple: it checks an explicit permission mapping for the channel. In a real system, this would call your authorization service (or a cached copy of the user’s roles) and check against resource-specific policies, not just channel names. The shape is what matters: every inbound message is gated.

This per-message check catches the case where a user’s permissions change while they are connected. But it only catches it if their in-memory user.permissions is up to date. Which brings us to layer 3.

Layer 3: Re-validate credentials periodically

The handshake authenticated the user. The per-message checks use the user object attached at connection time. But the user object is a snapshot of a moment in time. If an admin revokes the user’s access at 10:05, but the user connected at 09:55, their user.permissions still contains the revoked permissions.

The fix is a periodic heartbeat that re-validates the credentials. Think of it as your WebSocket version of refresh token rotation: you do not trust the original token for longer than a bounded window.

const REAUTH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes

wss.on('connection', (ws, req) => {
  const user = (req as any).user;
  let token = req.headers['authorization']?.slice(7) || '';

  // Periodically re-validate the token
  const reauthTimer = setInterval(async () => {
    try {
      // The client is expected to send an updated token via a 'reauth' message
      // if the original token has expired. If not, we use the original.
      const payload = verifyToken(token);
      (ws as any).user = payload; // Update the in-memory user
    } catch {
      // Token is invalid or expired. Close the connection.
      ws.send(JSON.stringify({
        type: 'reauth_required',
        message: 'token expired',
      }));
      ws.close(4001, 'token_expired');
      clearInterval(reauthTimer);
    }
  }, REAUTH_INTERVAL_MS);

  // Listen for re-auth messages from the client
  ws.on('message', (raw) => {
    let msg: Message;
    try {
      msg = JSON.parse(raw.toString());
    } catch {
      return;
    }

    if (msg.type === 'reauth' && typeof msg.payload === 'string') {
      try {
        const payload = verifyToken(msg.payload);
        token = msg.payload; // Update the stored token
        (ws as any).user = payload;
        ws.send(JSON.stringify({ type: 'reauth_ok' }));
      } catch {
        ws.send(JSON.stringify({ type: 'reauth_failed' }));
        ws.close(4001, 'reauth_failed');
      }
    }
  });

  ws.on('close', () => {
    clearInterval(reauthTimer);
  });
});

The client side handles the reauth_required message by fetching a fresh token and sending it back:

// client-side re-auth handler
ws.addEventListener('message', async (event) => {
  const msg = JSON.parse(event.data);

  if (msg.type === 'reauth_required') {
    const newToken = await getAccessToken(); // from your auth library
    ws.send(JSON.stringify({
      type: 'reauth',
      payload: newToken,
    }));
  }
});

This pattern covers both expiration (the token expired naturally) and revocation (the token was invalidated by the auth server before it expired). In both cases, the periodic check catches it within five minutes and closes the connection.

The five-minute window is a deliberate trade-off. A shorter window means faster revocation but more overhead from repeated verifyToken calls (which are asymmetric-crypto operations that cost CPU). A longer window saves CPU but leaves revoked connections alive longer. Five minutes is the sweet spot for most production services. If you need tighter revocation, push the check to one minute and cache the token verification results in Redis.

Revocation via server-side push

For instant revocation (not waiting for the next periodic check), wire a pub/sub channel that carries revocation events from your auth service to all WebSocket server instances.

// subscriber-side: listen for revocation events from Redis
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

// Map userId -> Set<WebSocket>
const userSockets = new Map<string, Set<WebSocket>>();

// When a connection is established, register it
wss.on('connection', (ws, req) => {
  const user = (req as any).user;
  if (!userSockets.has(user.id)) {
    userSockets.set(user.id, new Set());
  }
  userSockets.get(user.id)!.add(ws);

  ws.on('close', () => {
    userSockets.get(user.id)?.delete(ws);
    if (userSockets.get(user.id)?.size === 0) {
      userSockets.delete(user.id);
    }
  });
});

// Subscribe to revocation events
await redis.subscribe('auth:revocation', (message) => {
  const { userId } = JSON.parse(message);
  const sockets = userSockets.get(userId);
  if (sockets) {
    for (const ws of sockets) {
      ws.close(4001, 'session_revoked');
    }
  }
});

The auth service publishes to Redis whenever a token is revoked or a user’s permissions change:

// auth-service.ts: publish revocation when a session is invalidated
await redis.publish('auth:revocation', JSON.stringify({ userId: 'user_123' }));

This gives you sub-second revocation without polling. Every WebSocket server instance subscribes to the same Redis channel and closes the relevant connections immediately.

The practical choice: short-lived access tokens + periodic re-auth

Three patterns for WebSocket auth are common in production, and exactly one of them balances security and complexity well:

PatternSecurityComplexityNotes
Static token at connectLowLowToken works until the connection closes. Cannot revoke.
Short-lived token + periodic re-authHighMediumClose connections within minutes of revocation.
Token per messageHighHighEvery message carries a fresh token. Bandwidth-heavy.

The short-lived token with periodic re-auth (5-minute window, as shown above) is the practical default. It handles the common cases (expired token, revoked session, permission change) without requiring every message to carry a full JWT, which can be 500+ bytes each.

Token-per-message makes sense in environments where you need instant per-action authorization and bandwidth is not a concern (internal infrastructure, not public-facing mobile apps). Static token makes sense for ephemeral connections that live a few seconds (a WebSocket used for a single file transfer, then closed).

The four things that will break in production

1. The Upgrade event does not fire behind a proxy

If your WebSocket traffic goes through a load balancer, CDN, or reverse proxy that does not forward the Authorization header during the Upgrade, your req.headers['authorization'] will be empty on the server side.

The fix is to configure your proxy to forward headers through the Upgrade. For Nginx:

location /ws {
  proxy_pass http://backend;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Authorization $http_authorization;
  proxy_pass_header Authorization;
}

For AWS ALB, enable “Preserve Host Header” and note that ALB does not forward custom headers during the WebSocket upgrade by default. You may need to use the query-parameter approach (with the caveats about logging above) or terminate TLS at the ALB and use a cookie.

2. The verifyToken call blocks the event loop

JWT verification is an asymmetric crypto operation. If you have 10,000 concurrent connections all sending their first message at the same time (a reconnect storm after a deployment), the verifyToken calls in the periodic re-auth interval will block the event loop and spike latency for every other request on the server.

Cache the verification result. Use a simple in-memory Map<string, CachedPayload> with a TTL that is shorter than your re-auth interval:

const verifyCache = new Map<string, { payload: User; cachedAt: number }>();
const CACHE_TTL_MS = 60_000;

function verifyTokenCached(token: string): User {
  const cached = verifyCache.get(token);
  if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
    return cached.payload;
  }
  const payload = verifyToken(token);
  verifyCache.set(token, { payload, cachedAt: Date.now() });
  // Evict old entries periodically
  if (verifyCache.size > 10_000) {
    const oldest = [...verifyCache.entries()]
      .sort(([, a], [, b]) => a.cachedAt - b.cachedAt)[0];
    verifyCache.delete(oldest[0]);
  }
  return payload;
}

This transforms a crypto operation into a Map lookup for repeated tokens. In a reconnect storm, most clients will present the same token they had before the restart (because they are still within the token’s validity window), and the cache absorbs the load.

3. The reauth message race condition

If the client sends a reauth message with a new token at the same time the server’s periodic check fires (using the old token), the server sees an expired token and closes the connection, even though a valid token is in flight.

The fix is to use an epoch counter on the connection. When the client sends a reauth message, increment the epoch. When the periodic check fires, it only closes the connection if the epoch has not changed since the last successful check.

wss.on('connection', (ws, req) => {
  let epoch = 0;
  let lastValidEpoch = 0;

  const reauthTimer = setInterval(() => {
    if (epoch === lastValidEpoch) {
      // No re-auth since last check; validate the current token
      try {
        verifyToken(token);
        lastValidEpoch = epoch;
      } catch {
        ws.close(4001, 'token_expired');
        clearInterval(reauthTimer);
      }
    } else {
      // A re-auth happened between checks; update the baseline
      lastValidEpoch = epoch;
    }
  }, REAUTH_INTERVAL_MS);

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());
    if (msg.type === 'reauth') {
      try {
        verifyToken(msg.payload);
        token = msg.payload;
        epoch++; // Signal that re-auth happened
        ws.send(JSON.stringify({ type: 'reauth_ok' }));
      } catch {
        ws.send(JSON.stringify({ type: 'reauth_failed' }));
      }
    }
  });
});

This is a small addition that prevents the production outage of “deploying a new version while thousands of clients are mid-reauth.”

4. The authorization service is down during message validation

Your per-message authorize function calls an external service to check permissions. That service goes down. Now every WebSocket message fails authorization and every client gets disconnected for something that is not their fault.

Cache the authorization decisions with a TTL:

const authzCache = new Map<string, { allowed: boolean; cachedAt: number }>();
const AUTHZ_CACHE_TTL_MS = 120_000;

async function authorizeCached(user: User, channel: string): Promise<boolean> {
  const key = `${user.id}:${channel}`;
  const cached = authzCache.get(key);

  if (cached && Date.now() - cached.cachedAt < AUTHZ_CACHE_TTL_MS) {
    return cached.allowed;
  }

  try {
    const allowed = await checkPermission(user.id, channel);
    authzCache.set(key, { allowed, cachedAt: Date.now() });
    return allowed;
  } catch {
    // Auth service is down. Fall back to the cached decision, even if stale.
    if (cached) return cached.allowed;
    // No cache entry at all: deny closed by default.
    return false;
  }
}

This pattern means a brief auth service outage does not cascade into a WebSocket disconnect storm. Users keep receiving data for up to two minutes based on their last known permissions. When the auth service recovers, the cache refreshes and decisions are live again.

What to monitor

Three metrics tell you whether your WebSocket auth is healthy:

ws.auth.handshake_failures — The rate of failed authentication during the Upgrade. A spike means someone is probing your endpoint with bad tokens, or your token issuer is down. Either way, you want to know.

ws.auth.reauth_success_rate — The percentage of re-auth attempts that succeed. If this drops below 99% during normal operation, your tokens are expiring faster than clients can refresh them, or your re-auth window is too tight.

ws.auth.revocation_closes — The rate of connections closed by server-side revocation (not by token expiration or client disconnect). This should be near zero during normal operation and spike when an admin revokes a compromised session or pushes a permission update. If it is always zero, your revocation channel is not working.

Add these to your Prometheus metrics on day one:

import { Counter } from '@opentelemetry/api';

const handshakeFailures = meter.createCounter('ws.auth.handshake_failures');
const reauthAttempts = meter.createCounter('ws.auth.reauth_attempts');
const reauthSuccesses = meter.createCounter('ws.auth.reauth_successes');
const revocationCloses = meter.createCounter('ws.auth.revocation_closes');

Without these metrics, you are flying blind. The authentication layer is invisible in normal HTTP request metrics (because WebSocket connections are not HTTP requests after the Upgrade), and you will not notice when it fails until a security incident is already in progress.

The takeaway

WebSocket authentication is not harder than REST authentication. It is different, and treating it the same way is how you end up with active connections feeding data to users whose sessions were revoked an hour ago.

The three-layer pattern is straightforward: validate the token at handshake time, authorize every message against the user’s current permissions, and re-validate credentials on a timer. Add a Redis pub/sub channel if you need sub-second revocation. Cache token verification results to survive reconnect storms. Cache authorization decisions to survive auth service outages.

The code is under 150 lines. The impact is closing the longest-lived security gap in your real-time infrastructure: the connection that should have been closed but was not.


A note from Yojji

The WebSocket authentication pattern in this post (per-message authorization, periodic credential re-validation, and revocation channels) is the kind of architectural rigor that separates a prototype from a production system. It is also the kind of work that is easy to skip in a sprint and painful to retrofit after a security incident.

Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams specialize in building reliable Node.js backends, real-time infrastructure, and secure distributed systems using React, Node.js, TypeScript, and cloud platforms.

If your team needs to ship real-time features without compromising on security or reliability, and you would rather start with the right architecture than learn these lessons after a production incident, Yojji builds the kind of systems where this pattern is standard practice, not a post-incident fix.