Skip to main content

Auth in Real-Time Apps: Secure WebSocket

Real-time chat must be secure: unauthorized users should not receive messages from private channels, and token expiration must not orphan WebSocket connections. This guide covers authenticating WebSocket connections with JWTs, refreshing tokens before expiry, enforcing channel-level permissions, and gracefully handling auth failures. The challenge is that HTTP and WebSocket use different auth patterns—HTTP has request headers, WebSocket only has the initial handshake—so you must send the token strategically and handle token refresh without dropping the connection.

A common mistake is sending the token only in the WebSocket URL (e.g., wss://api.chat.example.com/socket?token=...), which exposes it in browser history and logs. Instead, send the token as the first WebSocket message after connect, or use secure headers during the handshake (available in Node/Express).

JWT-Based Authentication Flow

Your server issues a short-lived JWT (15 min) and a refresh token (7 days) via HTTP login:

async function loginUser(email, password) {
const response = await fetch('https://api.chat.example.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});

if (!response.ok) {
throw new Error('Login failed');
}

const { accessToken, refreshToken, user } = await response.json();

// Store tokens securely
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('user', JSON.stringify(user));

return { accessToken, user };
}

Decode and validate the JWT on the client:

function parseJwt(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (e) {
return null;
}
}

function isTokenExpired(token) {
const payload = parseJwt(token);
if (!payload) return true;
return payload.exp * 1000 < Date.now();
}

Token Refresh Mechanism

Refresh the access token before it expires:

async function refreshAccessToken() {
const refreshToken = localStorage.getItem('refreshToken');

if (!refreshToken) {
throw new Error('No refresh token available');
}

const response = await fetch('https://api.chat.example.com/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});

if (!response.ok) {
// Refresh failed; user must re-login
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
throw new Error('Token refresh failed');
}

const { accessToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
return accessToken;
}

function useTokenRefresh() {
const [token, setToken] = useState(localStorage.getItem('accessToken'));
const tokenRefreshIntervalRef = useRef(null);

useEffect(() => {
if (!token) return;

// Calculate time until token expires (with 1-minute buffer)
const payload = parseJwt(token);
const expiresAt = payload.exp * 1000;
const now = Date.now();
const timeUntilExpiry = expiresAt - now - 60000;

if (timeUntilExpiry <= 0) {
// Token already expired; refresh immediately
refreshAccessToken()
.then(setToken)
.catch(() => setToken(null));
} else {
// Schedule refresh before expiry
tokenRefreshIntervalRef.current = setTimeout(() => {
refreshAccessToken()
.then(setToken)
.catch(() => setToken(null));
}, timeUntilExpiry);
}

return () => {
if (tokenRefreshIntervalRef.current) {
clearTimeout(tokenRefreshIntervalRef.current);
}
};
}, [token]);

return { token, setToken };
}

Authenticating WebSocket Connection

Send the JWT as the first message after the WebSocket opens:

class AuthenticatedWebSocketClient {
constructor(url, token, handlers) {
this.url = url;
this.token = token;
this.handlers = handlers;
this.ws = null;
this.isAuthenticated = false;
}

connect() {
return new Promise((resolve, reject) => {
try {
// Connect without token in URL
this.ws = new WebSocket(this.url);

this.ws.onopen = () => {
// Send auth message immediately
this.ws.send(
JSON.stringify({
type: 'auth',
payload: { token: this.token },
})
);

// Wait for auth_ok response before considering connected
};

this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);

if (data.type === 'auth_ok') {
this.isAuthenticated = true;
if (this.handlers.onOpen) {
this.handlers.onOpen();
}
resolve();
} else if (data.type === 'auth_error') {
this.isAuthenticated = false;
const error = new Error(
`Auth failed: ${data.payload.reason}`
);
if (this.handlers.onError) {
this.handlers.onError(error);
}
this.ws.close();
reject(error);
} else if (this.isAuthenticated) {
// Only handle other messages after authenticated
this.handleMessage(data);
}
};

this.ws.onerror = (error) => {
if (this.handlers.onError) {
this.handlers.onError(error);
}
reject(error);
};

this.ws.onclose = () => {
this.isAuthenticated = false;
};
} catch (err) {
reject(err);
}
});
}

handleMessage(data) {
if (data.type === 'auth_expired') {
// Server says our token expired; reconnect with new token
if (this.handlers.onTokenExpired) {
this.handlers.onTokenExpired();
}
this.isAuthenticated = false;
} else if (data.type === 'forbidden') {
// User lacks permission for this channel
if (this.handlers.onForbidden) {
this.handlers.onForbidden(data.payload);
}
} else if (this.handlers.onMessage) {
this.handlers.onMessage(data);
}
}

send(message) {
if (this.isAuthenticated && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}

close() {
if (this.ws) {
this.ws.close();
}
}
}

Handling Token Expiration and Reconnection

When the token expires mid-session, refresh and reconnect:

function ChatProvider({ children }) {
const { token, setToken } = useTokenRefresh();
const [state, dispatch] = useReducer(chatReducer, initialState);
const wsClientRef = useRef(null);

const reconnectWithNewToken = useCallback(async () => {
try {
const newToken = await refreshAccessToken();
setToken(newToken);

// Reconnect WebSocket with new token
wsClientRef.current?.close();
wsClientRef.current = new AuthenticatedWebSocketClient(
'wss://api.chat.example.com/socket',
newToken,
{
onOpen: () => {
dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'connected' });
},
onMessage: (data) => {
dispatch({ type: 'ADD_MESSAGE', payload: data.payload });
},
onTokenExpired: reconnectWithNewToken,
onForbidden: (payload) => {
console.warn(
'Access denied to channel:',
payload.channelId,
payload.reason
);
},
}
);

await wsClientRef.current.connect();
} catch (err) {
console.error('Failed to refresh token:', err);
dispatch({ type: 'LOGOUT' });
}
}, [setToken]);

useEffect(() => {
if (!token) return;

const wsClient = new AuthenticatedWebSocketClient(
'wss://api.chat.example.com/socket',
token,
{
onOpen: () => {
dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'connected' });
},
onMessage: (data) => {
dispatch({ type: 'ADD_MESSAGE', payload: data.payload });
},
onTokenExpired: reconnectWithNewToken,
onError: () => {
dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'disconnected' });
},
}
);

wsClientRef.current = wsClient;
wsClient.connect();

return () => wsClient.close();
}, [token, reconnectWithNewToken]);

return (
<ChatContext.Provider value={{ state, dispatch }}>
{children}
</ChatContext.Provider>
);
}

Server-Side JWT Validation (Node Example)

// Node.js / Express-ws example
const jwt = require('jsonwebtoken');

wss.on('connection', (ws) => {
let userId = null;
let isAuthenticated = false;

ws.on('message', (msg) => {
const data = JSON.parse(msg);

if (data.type === 'auth') {
const { token } = data.payload;

try {
const payload = jwt.verify(
token,
process.env.JWT_SECRET,
{ maxAge: '15m' }
);
userId = payload.sub; // Subject = user ID
isAuthenticated = true;

ws.send(JSON.stringify({ type: 'auth_ok' }));
} catch (err) {
ws.send(
JSON.stringify({
type: 'auth_error',
payload: { reason: err.message },
})
);
ws.close();
}
} else if (!isAuthenticated) {
ws.send(
JSON.stringify({
type: 'auth_error',
payload: { reason: 'Not authenticated' },
})
);
ws.close();
} else if (data.type === 'message') {
const { channelId, text } = data.payload;

// Check user's permission for this channel
if (!userHasAccessTo(userId, channelId)) {
ws.send(
JSON.stringify({
type: 'forbidden',
payload: {
channelId,
reason: 'No access to this channel',
},
})
);
return;
}

// Process message...
}
});

// Validate token periodically
const tokenCheckInterval = setInterval(() => {
if (isAuthenticated && tokenIsExpired(userId)) {
ws.send(
JSON.stringify({
type: 'auth_expired',
payload: { reason: 'Token expired' },
})
);
isAuthenticated = false;
}
}, 60000);

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

Key Takeaways

  • Send token as first WebSocket message, not in the URL, to avoid exposure in logs and history.
  • Refresh access token before expiry using a scheduled background task.
  • Reconnect WebSocket with new token when the old one expires.
  • Validate permissions per-message: Reject messages to channels the user cannot access.

Frequently Asked Questions

Should I store tokens in localStorage or sessionStorage?

localStorage persists across page reloads (better UX); sessionStorage clears on tab close (more secure). For chat apps, localStorage is standard. Always use HTTPS to prevent token theft.

What if the refresh token itself expires?

Gracefully redirect to the login page. The refresh token has a longer expiry (days), but once it's gone, the user's session is truly over and they must re-authenticate.

How do I handle WebSocket reconnections with a new token?

Store the new token in a ref or state, then close and reconnect the WebSocket with the new token. Queue messages during the reconnection window so they're not lost.

Can I use cookies instead of localStorage for tokens?

Yes, with httpOnly cookies (set secure; httpOnly headers). The browser automatically includes them in requests. However, cookies require setting up CORS correctly and don't work as well with WebSocket (you'd need to set cookies before the WebSocket handshake in some configurations).

Further Reading