Implementing Secure "Remember Me" with Refresh Tokens in JavaScript Applications
Wenhao Wang
Dev Intern · Leapcell

Introduction
In the fast-paced world of web applications, user convenience is paramount. One feature that significantly enhances this convenience is "Remember Me," allowing users to stay logged in across browser sessions without repeatedly entering their credentials. While seemingly straightforward, implementing a secure and long-lasting "Remember Me" functionality poses interesting challenges, especially concerning user data protection and authorization longevity. Traditional approaches often fall short in balancing security with persistence. This article delves into a robust solution for JavaScript applications: leveraging refresh tokens as a long-term, secure "Remember Me" strategy. We will explore the underlying concepts, discuss implementation details, and demonstrate how to build an effective and secure system.
Core Concepts Explained
Before diving into the implementation, let's define some key terms that will form the backbone of our discussion:
- Access Token: A credential that is used to access protected resources. Access tokens are typically short-lived (minutes to an hour) and are included in every request to secure APIs. Their short lifespan minimizes the risk from token theft.
- Refresh Token: A long-lived credential used to obtain new access tokens after the current one expires. Unlike access tokens, refresh tokens are not sent with every API request. They are stored more securely and used less frequently, typically only when an access token needs to be renewed.
- JWT (JSON Web Token): A compact, URL-safe means of representing claims to be transferred between two parties. JWTs are often used for access tokens, containing information like user ID, roles, and expiration time, digitally signed to ensure integrity.
- Session vs. Persistence: A session typically refers to a temporary, interactive information interchange between two or more communicating devices or programs. Persistence, in this context, means the ability for the user's logged-in state to survive browser closures or extended periods of inactivity.
- HTTP-only Cookie: A special type of cookie that is inaccessible to client-side JavaScript. This makes it immune to cross-site scripting (XSS) attacks, as an attacker cannot simply read the cookie's value. This is a crucial security measure for storing sensitive tokens like refresh tokens.
- CSRF (Cross-Site Request Forgery) Token: A secret, unique, and unpredictable value generated by the server and sent to the client. The client must then include this token in subsequent requests, typically in a header or form field. The server validates this token, preventing unauthorized requests initiated from other domains.
Implementing "Remember Me" with Refresh Tokens
The core idea is to use a short-lived access token for immediate API requests and a long-lived refresh token, stored securely, to re-issue new access tokens when needed. This approach provides a strong balance between security and user convenience.
The Flow
-
User Login:
- The user provides credentials (username/password).
- The backend authenticates the user.
- If successful, the backend issues an access token and a refresh token.
- The access token is typically sent in the response body or a non-HTTP-only cookie.
- The refresh token is set as an HTTP-only, secure cookie from the server. This prevents JavaScript from accessing it, mitigating XSS risks.
- If the "Remember Me" checkbox is checked, the refresh token's expiration time is set to be much longer (e.g., 30 days to several months). Otherwise, it might be a standard session cookie or a shorter duration.
-
Subsequent API Requests:
- The JavaScript client includes the access token (e.g., in the
Authorization: Bearer <token>
header) with every protected API request.
- The JavaScript client includes the access token (e.g., in the
-
Access Token Expiration:
- When the access token expires, an API request with the old token will fail (e.g., with a 401 Unauthorized status code).
- The client-side JavaScript detects this 401 error.
-
Token Refresh:
- Upon detecting an expired access token, the client makes a request to a dedicated "token refresh" endpoint on the server.
- This request automatically sends the HTTP-only refresh token cookie to the server.
- The server validates the refresh token:
- Checks its validity and expiration.
- Ensures it hasn't been revoked.
- (Optional but recommended) Validates any associated CSRF token.
- If valid, the server issues a new access token and potentially a new refresh token (token rotation for enhanced security).
- The new access token is sent back to the client. If a new refresh token is issued, it overwrites the old one in an HTTP-only cookie.
-
Retry Original Request:
- With the new access token, the client retries the original failed API request.
Code Examples (Conceptual, Frontend & Backend)
Let's illustrate with simplified JavaScript (frontend) and Node.js (backend) examples.
Frontend (JavaScript - using axios
for requests)
// A simple store for the access token (in a real app, use a more robust state management) let accessToken = null; // Function to store the access token const setAccessToken = (token) => { accessToken = token; // In a real app, you might also store in localStorage for immediate access on page refresh // localStorage.setItem('accessToken', token); }; // Function to get the access token const getAccessToken = () => { // return accessToken || localStorage.getItem('accessToken'); return accessToken; }; // Axios instance with interceptor for token handling const api = axios.create({ baseURL: '/api', }); api.interceptors.request.use( (config) => { const token = getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // If the error is 401 and not already retrying if (error.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true; // Mark as retried try { // Request a new access token using the refresh token (which is in HTTP-only cookie) const refreshResponse = await axios.post('/api/auth/refresh-token'); setAccessToken(refreshResponse.data.accessToken); // Update the original request's header with the new token originalRequest.headers.Authorization = `Bearer ${refreshResponse.data.accessToken}`; // Retry the original request return api(originalRequest); } catch (refreshError) { // Refresh token failed, potentially expired or invalid. // Log the user out or redirect to login. console.error("Refresh token failed:", refreshError); // Clear any stored tokens and redirect to login setAccessToken(null); // localStorage.removeItem('accessToken'); window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); // --- User Login Example --- async function loginUser(username, password, rememberMe) { try { const response = await axios.post('/api/auth/login', { username, password, rememberMe, // Send "remember me" preference }); setAccessToken(response.data.accessToken); // Backend will set the refresh token as an HTTP-only cookie console.log("Logged in successfully!"); // Redirect to dashboard or home page } catch (error) { console.error("Login failed:", error.response?.data?.message || error.message); } } // Example usage: // loginUser('user@example.com', 'password123', true); // api.get('/user/profile').then(res => console.log(res.data));
Backend (Node.js with Express & JWT)
const express = require('express'); const jwt = require('jsonwebtoken'); const cookieParser = require('cookie-parser'); // For parsing HTTP-only cookies const csrf = require('csurf'); // For CSRF protection const app = express(); app.use(express.json()); app.use(cookieParser()); const JWT_SECRET = 'your_jwt_secret_key'; // Use a strong, environment variable const REFRESH_TOKEN_SECRET = 'your_refresh_token_secret_key'; // Use a strong, environment variable // Setup CSRF protection, using a cookie for the CSRF token itself const csrfProtection = csrf({ cookie: true }); app.use(csrfProtection); // Apply globally or to specific routes // Dummy user data const users = [{ id: 1, username: 'user@example.com', password: 'password123' }]; // Helper to generate tokens const generateTokens = (user, rememberMe) => { const accessToken = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '15m' }); // Short-lived // Refresh token expiry based on 'rememberMe' const refreshTokenExpiry = rememberMe ? '30d' : '1h'; // 30 days vs 1 hour const refreshToken = jwt.sign({ userId: user.id }, REFRESH_TOKEN_SECRET, { expiresIn: refreshTokenExpiry }); return { accessToken, refreshToken }; }; // Middleware to verify access token const authenticateAccessToken = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) return res.status(401).json({ message: 'No access token provided' }); const token = authHeader.split(' ')[1]; if (!token) return res.status(401).json({ message: 'Token format is Bearer <token>' }); jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.status(401).json({ message: 'Invalid or expired access token' }); req.user = user; next(); }); }; // --- AUTH ENDPOINTS --- // Login app.post('/api/auth/login', (req, res) => { const { username, password, rememberMe } = req.body; const user = users.find(u => u.username === username && u.password === password); if (!user) { return res.status(401).json({ message: 'Invalid credentials' }); } const { accessToken, refreshToken } = generateTokens(user, rememberMe); // Set refresh token as HTTP-only secure cookie res.cookie('refreshToken', refreshToken, { httpOnly: true, // Cannot be accessed by client-side JS secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production sameSite: 'Lax', // CSRF protection - prevent sending with cross-site requests maxAge: (rememberMe ? 30 * 24 * 60 * 60 * 1000 : 60 * 60 * 1000), // 30 days or 1 hour }); // Set CSRF token in an accessible cookie or header for the client to read const csrfToken = req.csrfToken(); res.cookie('XSRF-TOKEN', csrfToken); // For client-side access res.json({ accessToken, csrfToken }); // Send CSRF token in response body too }); // Token Refresh app.post('/api/auth/refresh-token', csrfProtection, (req, res) => { // Apply CSRF protection const refreshToken = req.cookies.refreshToken; if (!refreshToken) { return res.status(401).json({ message: 'No refresh token provided' }); } jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => { if (err) { // Clear expired/invalid refresh token res.clearCookie('refreshToken'); return res.status(403).json({ message: 'Invalid or expired refresh token' }); } // Token rotation: Issue a new refresh token along with a new access token const { accessToken, refreshToken: newRefreshToken } = generateTokens(user, true); // Assume 'rememberMe' is true if using refresh token res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }); const csrfToken = req.csrfToken(); res.cookie('XSRF-TOKEN', csrfToken); res.json({ accessToken, csrfToken }); }); }); // Logout (revoke refresh token if stored in DB) app.post('/api/auth/logout', (req, res) => { // In a real app, you might want to revoke the refresh token from a database res.clearCookie('refreshToken'); res.clearCookie('XSRF-TOKEN'); // Clear CSRF token as well res.status(200).json({ message: 'Logged out successfully' }); }); // --- PROTECTED ROUTES --- // Example protected route app.get('/api/user/profile', authenticateAccessToken, (req, res) => { res.json({ message: `Welcome, user ${req.user.userId}! Your profile data here.` }); }); app.listen(3000, () => console.log('Server running on port 3000'));
Security Considerations
- HTTP-only Cookies for Refresh Tokens: This is critical. It prevents JavaScript from accessing the refresh token, making it invulnerable to XSS attacks where an attacker might inject malicious scripts to steal cookies.
Secure
Flag for Cookies: Ensure thesecure
flag is set totrue
in production environments. This ensures the cookie is only sent over HTTPS connections, protecting it from eavesdropping.SameSite
Flag for Cookies: Setting theSameSite
attribute (e.g.,Lax
orStrict
) on the refresh token cookie helps mitigate CSRF attacks by instructing browsers not to send the cookie with cross-site requests.- CSRF Protection for Refresh Endpoint: Even with
SameSite
, it's good practice to implement additional CSRF protection on your/refresh-token
endpoint. This usually involves a CSRF token stored in a regular (non-HTTP-only) cookie orlocalStorage
that the client sends with the refresh request, and the server validates. - Token Revocation: Implement a mechanism to revoke refresh tokens (e.g., storing them in a database and marking them invalid on logout or account compromise). This is crucial for security incident response.
- Token Rotation: Issuing a new refresh token with each successful token refresh (and invalidating the old one) is a best practice. If an attacker steals a refresh token, it becomes useless after the next legitimate refresh operation.
- Rate Limiting: Protect your login and token refresh endpoints against brute-force attacks by implementing rate limiting.
- Environment Variables: Never hardcode sensitive secrets (like JWT secrets) directly in your code. Use environment variables.
Application Scenarios
This refresh token strategy is ideal for:
- Single Page Applications (SPAs): React, Vue, Angular applications where persistent login is expected without full page reloads.
- Mobile Applications (Hybrid/PWAs): Provides a seamless login experience for mobile users.
- Any client-side application requiring a long-lived authenticated state while maintaining strong security.
Conclusion
Implementing a secure "Remember Me" feature is a non-trivial task, but by meticulously leveraging refresh tokens and following robust security practices, developers can significantly enhance both user experience and application security. The approach of pairing short-lived access tokens with long-lived, HTTP-only refresh tokens, reinforced by measures like token rotation and CSRF protection, provides a resilient and industry-standard solution for persistent authentication in JavaScript applications. It effectively balances the desire for user convenience with the imperative of protecting sensitive user data.