Skip to main content

WebSocket Gateway Authentication

Status: RS256 enforced in production (AP-CC-3, 2026-04-29). HS256 remains as a dev/test fallback only — production tokens are rejected if the JWT header alg is anything other than RS256, and the service refuses to boot if JWT_PUBLIC_KEY is not set.

KeyNova exposes two WebSocket gateways for real-time staff updates: housekeeping (in hmp-housekeeping-service) and kitchen (in hmp-pos-service). Both validate JWTs on the Socket.IO connection event and enforce property scope on every room-join.


Why WebSockets Need Their Own Auth Path​

The HTTP API uses JwtAuthGuard which runs on every request. Socket.IO connections are long-lived: a single TCP/WebSocket upgrade carries hundreds of subsequent messages. If authentication runs only on the upgrade and the rest of the lifecycle is implicit, then:

  1. A connection with no token at all could send arbitrary events.
  2. A token bound to property A could be used to subscribe to events for property B by joining the wrong room.

The fix is two layers: validate the JWT on handleConnection, then re-verify the property scope on every joinProperty / joinFloor / joinOutlet action.


Algorithm Selection​

The gateway does not read a JWT_ALGORITHM environment variable. It inspects the alg field in the inbound JWT header itself and chooses the verification path:

Inbound algNODE_ENV=productionBehavior
RS256anyVerified with JWT_PUBLIC_KEY (PEM string env var). Rejected if JWT_PUBLIC_KEY is unset.
HS256productionRejected — production accepts RS256 only.
HS256dev / testVerified with JWT_SECRET (HS256 fallback).
anything elseanyRejected.

The check is implemented inline in handleConnection:

const headerB64 = token.split('.')[0];
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
const alg = header.alg;

if (alg === 'RS256') {
const publicKey = process.env['JWT_PUBLIC_KEY'];
if (!publicKey) {
client.disconnect(true);
return;
}
payload = await this.jwtService.verifyAsync(token, {
publicKey,
algorithms: ['RS256'],
});
} else if (process.env['NODE_ENV'] === 'production') {
// Production rejects HS256
client.disconnect(true);
return;
} else {
payload = await this.jwtService.verifyAsync(token, {
secret: process.env['JWT_SECRET'],
});
}

A boot-time validator on each gateway (onModuleInit) throws if NODE_ENV=production and JWT_PUBLIC_KEY is absent, surfacing misconfiguration as a Kubernetes CrashLoopBackOff rather than a runtime authentication outage.


HousekeepingGateway Connection Flow​

Source: hmp-housekeeping-service/src/gateway/housekeeping.gateway.ts.

@WebSocketGateway({ namespace: '/housekeeping', cors: { origin: ..., credentials: true } })
export class HousekeepingGateway
implements OnGatewayConnection, OnModuleInit
{
constructor(private readonly jwtService: JwtService) {}

async onModuleInit(): Promise<void> {
if (process.env['NODE_ENV'] === 'production' && !process.env['JWT_PUBLIC_KEY']) {
throw new Error(
'JWT_PUBLIC_KEY is required in production for WebSocket RS256 authentication',
);
}
}

async handleConnection(client: Socket): Promise<void> {
const token = client.handshake.auth?.token;
if (!token) {
client.disconnect(true);
return;
}
// ... alg-aware verification (see "Algorithm Selection" above) ...
client.data.userId = payload.sub;
client.data.tenantId = payload.tenantId;
client.data.propertyId = payload.propertyId;
client.data.role = payload.role;
}
}

A connection with a missing, expired, or malformed token gets disconnected with no acknowledgement. The kitchen gateway in hmp-pos-service follows the identical pattern.

Token Source Order​

The gateways read the token from the Socket.IO handshake auth field:

  1. client.handshake.auth.token — preferred for mobile and dashboard clients
  2. client.handshake.query.token — query-string fallback (legacy)
  3. client.handshake.headers.authorization — Bearer <token> fallback

joinProperty and joinFloor Scope Enforcement​

After authentication, clients send a joinProperty event to subscribe to a property's updates. The gateway must verify that the requested property matches the JWT claim — a mobile client could otherwise spoof a different propertyId.

@SubscribeMessage('joinProperty')
handleJoinProperty(
@ConnectedSocket() client: Socket,
@MessageBody() data: { propertyId: string; tenantId?: string },
) {
this.assertPropertyScope(client, data.propertyId, data.tenantId);
client.join(`property:${data.propertyId}`);
return { event: 'joined', room: `property:${data.propertyId}` };
}

private assertPropertyScope(
client: Socket,
propertyId: string,
requestedTenantId?: string,
): void {
const scoped = client.data ?? {};

// Tenant guard: chain-level admins (no propertyId in JWT) still have
// a tenantId claim and cannot cross tenants.
if (requestedTenantId && scoped.tenantId !== requestedTenantId) {
throw new ForbiddenException('Not authorized to subscribe to this tenant');
}
// Property guard: per-property staff are pinned to their JWT's propertyId.
if (scoped.propertyId && scoped.propertyId !== propertyId) {
throw new ForbiddenException('Not authorized to subscribe to this property');
}
}

joinFloor (housekeeping) and joinOutlet (kitchen) wrap the same check before joining the narrower room. The room name is namespaced (property:<id>:floor:<n>, outlet:<id>) so cross-property leakage is structurally impossible.

KitchenGateway.handleJoinOutlet adds an extra DB lookup to verify the outlet's propertyId matches the JWT claim — this is required because the client only knows the outlet UUID, so a Socket.IO room name alone cannot prove cross-property safety.


Mobile Client Token Forwarding​

The Phase 1 mobile apps (hmp-mobile-apps/apps/{housekeeping,fnb}) use the shared @hmp/auth Zustand store for the access token and a per-app hook for the WebSocket subscription:

// apps/housekeeping/hooks/useRealtimeTasks.ts
export function useRealtimeTasks(propertyId: string) {
const { user } = useAuth(); // Zustand selector
const token = user?.accessToken; // RS256-signed by hmp-auth-service

useEffect(() => {
if (!propertyId || !token) return;
const socket = io(`${WS_URL}/housekeeping`, {
transports: ['websocket'],
auth: { token },
});
socket.on('connect', () => socket.emit('joinProperty', { propertyId }));
// ... event listeners ...
return () => socket.disconnect();
}, [propertyId, token]);
}

apps/fnb/hooks/useRealtimeKitchen.ts mirrors the pattern for the /kitchen namespace. Because the access token is minted by the auth-service /auth/login flow (already RS256), the mobile clients require no change for the production cutover — the gateway's RS256 path verifies the same token.

The dashboard's web client uses the same pattern via the useRealtimeUpdates hook in hmp-web-dashboard.


Required Environment Variables​

VariableProductionDev/Test
NODE_ENVproductionany
JWT_PUBLIC_KEYRequired — PEM string of the platform's RSA public keyRecommended (so dev tokens minted by auth-service still work)
JWT_SECRETOptional (unused for RS256 path)Required — HS256 fallback secret
CORS_ORIGINSComma-separated allowed Originslocalhost defaults
REDIS_HOSTRequired for multi-instance fan-outOptional

The QA, staging, and production compose files (hmp-infrastructure/docker/docker-compose.{qa,staging,prod}-aws.yml) all set JWT_PUBLIC_KEY either directly (JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}) or via JWT_PUBLIC_KEY_FILE: /app/keys/jwt-public.pem mounted from the EC2 host. The deploy runbook (hmp-infrastructure/scripts/deploy-qa.sh) re-mints INTERNAL_SERVICE_TOKEN and re-exports JWT_PUBLIC_KEY on every deploy — do not skip these steps or every cross-service and WebSocket call will return 503.


Test Coverage​

22+ tests across the gateways verify both connection auth and scope enforcement:

  • housekeeping.gateway.spec.ts — connection accept/reject for valid / invalid / missing tokens, RS256 vs HS256 alg selection, production HS256 rejection, property scope mismatch on joinProperty, floor scope on joinFloor
  • kitchen.gateway.spec.ts — same matrix for the kitchen namespace plus the outlet → propertyId DB cross-check on joinOutlet
  • Mobile hook tests verifying the token is read from the @hmp/auth store on every reconnect

Operational Runbook​

SymptomLikely CauseFix
All WebSocket connections rejected on QA after deployJWT_PUBLIC_KEY not exported into the containerRe-export and docker compose up -d the gateway services. See feedback_qa-internal-service-token.md.
Service crashloops with JWT_PUBLIC_KEY is required in productiononModuleInit boot validator firingSet JWT_PUBLIC_KEY in the env or compose common-backend-env.
Production rejects all tokens with Production rejected HS256 WebSocket tokenA non-prod / testing token got pushed to productionMint a fresh RS256 token via /auth/login. Investigate how the HS256 token reached prod.
Mobile client connects, then immediately disconnects with no errorToken is expired or property scope mismatchCheck token exp claim and propertyId claim against the requested room.

  • internal-service-auth.md — the broader JWT verification story (HTTP API + service-account tokens) that this gateway pattern shares
  • cross-service-communication.md — how the gateways emit events without needing direct DB access into other services
  • hmp-housekeeping-service/src/gateway/housekeeping.gateway.ts — implementation
  • hmp-pos-service/src/gateway/kitchen.gateway.ts — implementation