Building secure authentication systems remains a critical challenge in modern web development. I’ve implemented JWT-based authentication in multiple production applications and learned that proper token handling requires careful consideration. Let’s explore a robust approach that balances security and user experience.
Token expiration presents a key security measure. Short-lived access tokens (15-30 minutes) limit exposure if compromised. But frequent logouts damage user experience. Refresh tokens solve this by providing longer-lived credentials (7 days) exclusively for obtaining new access tokens. The real security boost comes when we rotate refresh tokens after each use.
Consider this server-side implementation for token refresh:
// Refresh token endpoint
app.post('/api/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const userId = decoded.id;
// Retrieve stored token from database
const storedToken = await db.refreshTokens.findUnique({
where: { userId }
});
// Critical security check
if (!storedToken || storedToken.token !== refreshToken) {
await db.refreshTokens.deleteMany({ where: { userId } });
return res.sendStatus(403);
}
// Generate new tokens
const newAccessToken = jwt.sign(
{ id: userId },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
);
const newRefreshToken = jwt.sign(
{ id: userId },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Update stored token
await db.refreshTokens.update({
where: { userId },
data: { token: newRefreshToken }
});
// Set secure cookies
res.cookie('accessToken', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 900000 // 15 minutes
});
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 604800000 // 7 days
});
res.json({ success: true });
} catch (error) {
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.sendStatus(403);
}
});
Token storage decisions significantly impact security. I always recommend HTTP-only cookies over localStorage. This prevents XSS attacks from stealing tokens. The sameSite=Strict
attribute adds CSRF protection. For additional security, pair this with a CSRF token for state-changing operations.
Client-side token management requires special attention. Here’s how I handle automatic token refreshing in React applications:
// Axios interceptor implementation
import axios from 'axios';
import { useEffect } from 'react';
export function useTokenRefresh() {
useEffect(() => {
const interceptor = axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
const status = error.response?.status;
if (status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Attempt refresh token request
await axios.post('/api/refresh', {}, {
withCredentials: true
});
// Retry original request
return axios(originalRequest);
} catch (refreshError) {
// Redirect to login on refresh failure
window.location.href = '/login?session_expired=1';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
return () => {
axios.interceptors.response.eject(interceptor);
};
}, []);
}
// Component implementation
function App() {
useTokenRefresh();
return (
<div className="App">
{/* Application components */}
</div>
);
}
Protection against token replay requires server-side tracking. When we detect reuse of a refresh token, we immediately invalidate all sessions for that user. This is how I implement token revocation in a PostgreSQL database:
-- Database schema for token tracking
CREATE TABLE refresh_tokens (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id)
);
-- Token verification pseudocode
async function verifyRefreshToken(userId, token) {
const storedHash = await getTokenHash(userId);
const currentHash = hashToken(token);
if (!storedHash || storedHash !== currentHash) {
// Critical security event - possible token theft
await deleteAllTokensForUser(userId);
return false;
}
return true;
}
Token generation must follow security best practices. I always use asymmetric cryptography for access tokens in multi-service environments:
// Asymmetric JWT signing example
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.ACCESS_PRIVATE_KEY,
{
algorithm: 'RS256',
expiresIn: '15m'
}
);
// Verification with public key
jwt.verify(accessToken, process.env.ACCESS_PUBLIC_KEY, (err, user) => {
// Handle verification
});
Dealing with concurrent requests presents another challenge. In high-traffic applications, I implement token queuing to prevent multiple refresh attempts:
// Request queuing implementation
let isRefreshing = false;
let failedRequests = [];
axios.interceptors.response.use(null, async error => {
if (error.response.status !== 401) return Promise.reject(error);
if (isRefreshing) {
return new Promise(resolve => {
failedRequests.push(() => resolve(axios(error.config)));
});
}
isRefreshing = true;
try {
await refreshTokens();
failedRequests.forEach(cb => cb());
failedRequests = [];
return axios(error.config);
} catch (refreshError) {
failedRequests.forEach(cb => cb(Promise.reject(refreshError)));
return Promise.reject(error);
} finally {
isRefreshing = false;
}
});
Security headers form the final defense layer. This configuration has served me well across multiple projects:
# NGINX security headers configuration
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;";
add_header Referrer-Policy "strict-origin-when-cross-origin";
Proper token expiration handling creates a security-conscious user experience. I implement gradual session warnings like this:
// Session expiration warning
function useSessionWarning() {
useEffect(() => {
const warningTimeout = setTimeout(() => {
showModal('Your session will expire in 5 minutes');
}, 10 * 60 * 1000); // 10 minutes
const expirationTimeout = setTimeout(() => {
redirectToLogin();
}, 15 * 60 * 1000); // 15 minutes
return () => {
clearTimeout(warningTimeout);
clearTimeout(expirationTimeout);
};
}, []);
}
Through careful implementation, we achieve both security and usability. The token rotation pattern significantly reduces risk without compromising user experience. Each project brings new insights, but these core principles provide a reliable foundation for authentication systems.