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
algis anything other thanRS256, and the service refuses to boot ifJWT_PUBLIC_KEYis 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:
- A connection with no token at all could send arbitrary events.
- 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 alg | NODE_ENV=production | Behavior |
|---|---|---|
RS256 | any | Verified with JWT_PUBLIC_KEY (PEM string env var). Rejected if JWT_PUBLIC_KEY is unset. |
HS256 | production | Rejected — production accepts RS256 only. |
HS256 | dev / test | Verified with JWT_SECRET (HS256 fallback). |
| anything else | any | Rejected. |
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:
client.handshake.auth.token— preferred for mobile and dashboard clientsclient.handshake.query.token— query-string fallback (legacy)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
| Variable | Production | Dev/Test |
|---|---|---|
NODE_ENV | production | any |
JWT_PUBLIC_KEY | Required — PEM string of the platform's RSA public key | Recommended (so dev tokens minted by auth-service still work) |
JWT_SECRET | Optional (unused for RS256 path) | Required — HS256 fallback secret |
CORS_ORIGINS | Comma-separated allowed Origins | localhost defaults |
REDIS_HOST | Required for multi-instance fan-out | Optional |
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 onjoinProperty, floor scope onjoinFloorkitchen.gateway.spec.ts— same matrix for the kitchen namespace plus the outlet → propertyId DB cross-check onjoinOutlet- Mobile hook tests verifying the token is read from the
@hmp/authstore on every reconnect
Operational Runbook
| Symptom | Likely Cause | Fix |
|---|---|---|
| All WebSocket connections rejected on QA after deploy | JWT_PUBLIC_KEY not exported into the container | Re-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 production | onModuleInit boot validator firing | Set JWT_PUBLIC_KEY in the env or compose common-backend-env. |
Production rejects all tokens with Production rejected HS256 WebSocket token | A non-prod / testing token got pushed to production | Mint a fresh RS256 token via /auth/login. Investigate how the HS256 token reached prod. |
| Mobile client connects, then immediately disconnects with no error | Token is expired or property scope mismatch | Check token exp claim and propertyId claim against the requested room. |
Related Docs
internal-service-auth.md— the broader JWT verification story (HTTP API + service-account tokens) that this gateway pattern sharescross-service-communication.md— how the gateways emit events without needing direct DB access into other serviceshmp-housekeeping-service/src/gateway/housekeeping.gateway.ts— implementationhmp-pos-service/src/gateway/kitchen.gateway.ts— implementation