How to secure session cookies for Strapi and Next.js
Learn step‑by‑step how to configure HttpOnly, Secure and SameSite cookies, rotate refresh tokens, and protect against CSRF in a Strapi 5 and Next.js stack.
By the Revisionary team · · 4 min read
Strapi 5 and Next.js can share a secure authentication flow when you configure session cookies correctly and enforce token rotation. By setting HttpOnly, Secure, and appropriate SameSite attributes, storing short‑lived access tokens in memory, and using rotating refresh tokens, you protect users from XSS, CSRF, and session hijacking.
1. Why cookie settings matter
- HttpOnly prevents JavaScript from reading the cookie, mitigating XSS theft.
- Secure ensures the cookie is only sent over HTTPS, stopping man‑in‑the‑middle sniffing.
- SameSite limits when the browser includes the cookie, blocking most CSRF attacks.
- Short expiration reduces the window for replay attacks.
If any of these flags are missing, an attacker who can inject script or lure a user to a malicious site may obtain or misuse the session.
2. Recommended authentication flow
- User logs in → Strapi validates credentials and issues a signed JWT access token (15‑30 minutes) and a refresh token (7 days).
- Access token is stored in a memory variable on the client (never in localStorage or cookies).
- Refresh token is stored in a Secure, HttpOnly, SameSite=Strict cookie.
- When the access token expires, the client sends a request to
/auth/refreshwith the refresh cookie; Strapi validates it, rotates the refresh token, and returns a new access token. - On logout, the refresh cookie is cleared and the server revokes the token.
This pattern follows OWASP recommendations for token‑based authentication and avoids the pitfalls of long‑lived cookies.
3. Configuring Strapi to set a secure refresh‑token cookie
Strapi’s built‑in authentication plugin can be extended with a custom controller that issues the refresh cookie. In src/api/auth/controllers/custom.js:
// src/api/auth/controllers/custom.js
import jwt from 'jsonwebtoken';
import { getService } from '@strapi/utils';
export default {
async login(ctx) {
const { identifier, password } = ctx.request.body;
const user = await getService('user').validateUser(identifier, password);
if (!user) {
return ctx.badRequest('Invalid credentials');
}
// Access token – short lived, returned in body
const accessToken = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '15m' });
// Refresh token – long lived, stored in HttpOnly cookie
const refreshToken = jwt.sign({ id: user.id }, process.env.JWT_REFRESH_SECRET, { expiresIn: '7d' });
ctx.cookies.set('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // requires HTTPS
sameSite: 'strict',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
});
ctx.send({ accessToken });
},
async refresh(ctx) {
const token = ctx.cookies.get('refreshToken');
if (!token) return ctx.unauthorized('No refresh token');
try {
const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
// Rotate token
const newRefresh = jwt.sign({ id: payload.id }, process.env.JWT_REFRESH_SECRET, { expiresIn: '7d' });
ctx.cookies.set('refreshToken', newRefresh, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
const newAccess = jwt.sign({ id: payload.id }, process.env.JWT_SECRET, { expiresIn: '15m' });
ctx.send({ accessToken: newAccess });
} catch (err) {
return ctx.unauthorized('Invalid refresh token');
}
},
async logout(ctx) {
ctx.cookies.set('refreshToken', null, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 0 });
ctx.send({ message: 'Logged out' });
},
};Key points
httpOnly: trueblocks client‑side access.secure: trueforces HTTPS; for local dev you can set it conditionally.sameSite: 'strict'prevents the cookie from being sent on cross‑site requests.- The refresh token is rotated on every use, limiting replay.
3.1 Registering the custom routes
Add the routes to src/api/auth/routes/custom.js:
// src/api/auth/routes/custom.js
module.exports = {
routes: [
{ method: 'POST', path: '/login', handler: 'custom.login' },
{ method: 'POST', path: '/refresh', handler: 'custom.refresh' },
{ method: 'POST', path: '/logout', handler: 'custom.logout' },
],
};Restart Strapi, and the endpoints are ready.
4. Next.js client implementation
Next.js can call the login endpoint, store the access token in a React context, and rely on the browser to send the refresh cookie automatically.
// lib/auth.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import axios from 'axios';
interface AuthContextProps {
accessToken: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [accessToken, setAccessToken] = useState<string | null>(null);
const login = async (email: string, password: string) => {
const res = await axios.post('/api/auth/login', { identifier: email, password });
setAccessToken(res.data.accessToken);
};
const logout = async () => {
await axios.post('/api/auth/logout');
setAccessToken(null);
};
// Automatic token refresh
const refresh = async () => {
try {
const res = await axios.post('/api/auth/refresh');
setAccessToken(res.data.accessToken);
} catch {
setAccessToken(null);
}
};
// Refresh token every 14 minutes (just before access token expires)
setInterval(refresh, 14 * 60 * 1000);
return (
<AuthContext.Provider value={{ accessToken, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
};Why not store the token in a cookie? Storing the access token in a cookie would expose it to XSS. Keeping it in memory means a page reload clears it, forcing a refresh flow that is protected by the HttpOnly cookie.
4.1 Adding CSRF protection (optional)
If you ever need to accept state‑changing requests that are not covered by the JWT flow (e.g., form posts), enable Strapi’s CSRF middleware and send the token as a header:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [{ key: 'X-CSRF-Token', value: process.env.CSRF_TOKEN }],
},
];
},
};On the server side, add the built‑in csrf plugin configuration in config/plugins.js:
module.exports = ({ env }) => ({
csrf: {
enabled: true,
// The token is sent as a cookie named `csrf-token`
cookie: { httpOnly: true, sameSite: 'strict', secure: true },
},
});The client can read the csrf-token cookie (it is not HttpOnly) and include it in the X-CSRF-Token header for POST/PUT/DELETE requests.
5. Hardening the Strapi server
Beyond cookie flags, apply the following defensive settings:
| Setting | Recommendation | Where to configure |
|---|---|---|
| Rate limiting | Limit login attempts (e.g., 5 per minute per IP) | Custom middleware or a plugin like strapi-plugin-rate-limit |
| Password hashing | Use Argon2 or bcrypt with cost factor ≥12 | Default in Strapi, verify bcrypt version |
| Audit log | Record successful/failed logins, token revocations | /docs/recover-and-audit/audit – enables traceability |
| Privacy | Anonymize IP addresses in audit logs | /docs/settings/privacy |
| Content‑type permissions | Restrict public role to only find on safe endpoints | Admin UI → Roles & Permissions |
Enabling the audit log gives you a tamper‑evident trail of authentication events, which is useful for incident response and GDPR compliance.
6. Testing your configuration
- Inspect cookies – In Chrome DevTools, verify that the
refreshTokencookie hasHttpOnly,Secure, andSameSite=Strictflags. - Attempt XSS – Try to read
document.cookiefrom a page that runs injected script; the refresh cookie should be invisible. - CSRF test – From another origin, send a POST request without the CSRF header; Strapi should reject it with
403. - Token rotation – After a refresh call, ensure the old refresh token is no longer accepted (you can try it manually with a tool like Postman).
7. Deploying to production
- Ensure your load balancer terminates TLS before forwarding to Strapi, but keep
securecookie flag active. - Use environment variables for secret keys (
JWT_SECRET,JWT_REFRESH_SECRET). - Set
NODE_ENV=productionso Strapi enables default security hardening. - Regularly rotate secret keys and invalidate existing refresh tokens via an admin endpoint.
8. When to consider Revisionary
If you need an immutable record of who changed authentication‑related settings (e.g., permission changes, token revocations), the Revisionary audit log records those actions alongside content edits. It provides a searchable history that can support compliance audits without custom development.
9. Checklist – secure sessions for Strapi + Next.js
- Set
HttpOnly,Secure,SameSite=Stricton the refresh‑token cookie. - Keep access tokens in memory, never in localStorage or cookies.
- Rotate refresh tokens on each use.
- Enforce short access‑token lifetimes (≤30 minutes).
- Enable CSRF protection for state‑changing endpoints.
- Add rate limiting on login and refresh endpoints.
- Record authentication events in the audit log.
- Anonymize IPs in audit logs for privacy compliance.
- Test cookie flags and token revocation after deployment.
Following this guide gives you a robust, OWASP‑aligned session strategy for headless CMS applications built with Strapi and Next.js.
The Revisionary team
Developers who build Revisionary, a version history plugin for Strapi 5. We write about Strapi, web development, AI and application security.
This article was written with the help of AI.