A beginner-friendly monorepo demonstrating secure JWT authentication using HttpOnly cookies with:
- Express.js (Node.js) backend
- Vite + React frontend
初心者向けモノレポ で、HttpOnly Cookie を使った安全な JWT 認証を示します:
- Express.js (Node.js) バックエンド
- Vite + React フロントエンド
With HttpOnly cookies, the token is stored securely by the browser and cannot be accessed via JavaScript. This means you don’t need to manually store the token or manage headers, and it provides better protection against XSS attacks.
HttpOnly Cookie を使うと、トークンはブラウザによって安全に保存され、JavaScript からアクセスできません。
これにより、トークンを手動で保存したりヘッダーを管理したりする必要がなくなり、XSS 攻撃からの保護も向上します。
Tip
HttpOnly cookies only work in web browsers. For mobile apps or non-browser clients, store tokens in memory or secure storage and send them via Authorization headers.
HttpOnly Cookie はウェブブラウザでのみ動作します。モバイルアプリやブラウザ以外のクライアントでは、トークンをメモリや安全なストレージに保存し、Authorization ヘッダーで送信してください。
git clone https://github.com/supershaneski/jwt-auth-example.git
cd jwt-auth-example
npm installCopy the example files:
# Server
cp apps/server/.env.example apps/server/.env
# Client
cp apps/client/.env.example apps/client/.envJWT_ACCESS_SECRET=your-super-secret-jwt-access-key-256-bits-here
JWT_REFRESH_SECRET=your-super-secret-refresh-key-256-bits-here
ACCESS_TOKEN_EXPIRY=120 # seconds (2 minutes)
REFRESH_TOKEN_EXPIRY=300 # seconds (5 minutes for testing)
NODE_ENV=development
PORT=3000VITE_API_BASE_URL=http://192.168.1.100:3000 # Use your local IP addressUse your local IP address, not localhost, to allow phone/tablet testing.
apps/server/src/cors/origins.js
export default [
'http://192.168.1.100:5173', // Replace with your IP address
]npm run devRuns:
- Client:
http://your-ip:5173 - Server:
http://your-ip:3000
- Open the client in your browser: http://your-ip:5173
- Press the Login button.
- Press Get Products. (This should succeed. See Console section in the DevTools)
- Wait 2 minutes (to allow the token to expire) → Press Get Products again → triggers token auto-refresh
Note
There is a simulated network delay in the backend route /api/products to help test retry and timeout behavior on the client side. To disable this delay, please comment out the following line in the server file:
apps/server/src/stubs/products.js
await sleep(delay)From the client, open the browser DevTools and check the Network tab.
Note
Be sure to set credentials: 'include' in the fetch options so the browser will send and store cookies.
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // <-- important
...
})1. Login POST /api/login →
If the client sends valid credentials, the server generates access and refresh tokens and sets the corresponding cookies for the response.
import { SignJWT } from 'jose'
const ACCESS_TOKEN_EXPIRY = Number(process.env.ACCESS_TOKEN_EXPIRY || 120)
const REFRESH_TOKEN_EXPIRY = Number(process.env.REFRESH_TOKEN_EXPIRY || 300)
const now = Math.floor(Date.now() / 1000)
const payload = {
sub: user.id,
username: user.username,
role: user.role,
iat: now,
}
const accessToken = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(now + ACCESS_TOKEN_EXPIRY)
.sign(accessSecret)
const refreshToken = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(now + REFRESH_TOKEN_EXPIRY)
.sign(refreshSecret)
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: ACCESS_TOKEN_EXPIRY * 1000,
})
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/refresh',
maxAge: REFRESH_TOKEN_EXPIRY * 1000,
})You’ll see the cookies under the Cookies section of the Network tab in the client.
Response Cookies
| Name | Value | Path | Expires | Max-Age | HttpOnly | SameSite |
|---|---|---|---|---|---|---|
| accessToken | eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsInVz... | / | 11/10/2025, 10:09:44 AM | 120 | ✓ | Strict |
| refreshToken | eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsInVz... | /api/refresh | 11/10/2025, 10:12:44 AM | 120 | ✓ | Strict |
Check the Path column. accessToken cookie will be automatically attached to all requests except /api/refresh while refreshToken cookie will be attached only when requesting /api/refresh.
2. Protected route GET /api/products →
When the user requests a protected route, you can see from the Cookies section of the Network tab that the accessToken cookie is attached to the request.
Request Cookies
| Name | Value |
|---|---|
| accessToken | eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsInVz... |
If the accessToken cookie is still valid, we can decode the JWT and extract the payload from the route handler.
import { jwtVerify } from 'jose'
const token = req.cookies?.accessToken
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
})
console.log(payload)If you request a protected route before logging in, or after the accessToken cookie has expired, no cookies will be attached, and you will get a 401 Unauthorized response. This is where we will handle token refresh.
Note
A protected route is a route or endpoint that is under some security scheme and requires authentication.
3. Token refresh POST /api/refresh →
When the user request the refresh route, the browser automatically attaches the refreshToken cookie.
Request Cookies
| Name | Value |
|---|---|
| refreshToken | eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsInVz... |
However, we also set expiration in our refreshToken cookie so if we request the refresh route after it expires, no cookies will be attached to the request. In that case, we will receive 401 Unauthorized again.
If the refreshToken cookie is still valid, we will receive new accessToken and refreshToken cookies.
4. Logout POST /api/logout →
In this example, logout is not a protected route. As such, there will be no cookies sent with the request. Even so, the logout handler in the backend will reset the cookies in the response.
res.clearCookie('accessToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
})
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/refresh',
})You can verify this at the Response Cookies in the client.
Response Cookies
| Name | Value | Path | Expires | Max-Age | HttpOnly | SameSite |
|---|---|---|---|---|---|---|
| accessToken | / | 1/1/1970, 9:00:00 AM | -- | ✓ | Strict | |
| refreshToken | /api/refresh | 1/1/1970, 9:00:00 AM | -- | ✓ | Strict |
Using HttpOnly cookies for JWT (or session) storage protects against XSS token theft, but leaves you vulnerable to Cross-Site Request Forgery (CSRF) attacks. In a CSRF attack, a malicious site tricks an authenticated user's browser into making an unwanted request to your app — and the browser automatically attaches HttpOnly cookies.
To mitigate this, we use the double-submit cookie pattern with a non-HttpOnly CSRF token.
When the user logs, we generate the CSRF token and set it to a (readable) cookie.
import { randomUUID } from 'crypto'
const csrfToken = randomUUID()
res.cookie('csrfToken', csrfToken, {
httpOnly: false, // Must be false so JS can read it
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: REFRESH_COOKIE_EXPIRY,
})You can check it from the Cookies section in the Network tab.
Response Cookies
| Name | Value | Path | Expires | Max-Age | HttpOnly | SameSite |
|---|---|---|---|---|---|---|
| csrfToken | d648682c-9e2b-44ed-8b6c-9fa65... | / | 11/10/2025, 10:09:44 AM | 300 | ✓ | Lax |
The client then reads the token from the cookie and stores it:
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrfToken='))
?.split('=')[1]We will then attach it as a custom header (e.g., X-XSRF-TOKEN) for every state-changing request.
In our example, we will use it when requesting the refresh endpoint.
POST /api/refresh HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: keep-alive
Content-Length: 0
Content-Type: application/json
Cookie: refreshToken=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsInVzZXJuYW1lIjoiYWxpY2UiLCJyb2xlIjoidXNlciIsImlhdCI6MTc2MjgxOTA5MCwiZXhwIjoxNzYyODE5MzkwfQ.2Gs_dQ_SzxJN0bW4cBOYhiZQq88w0AnY-NJD7bDGchU; csrfToken=5aee6a31-0100-4391-9f29-8631796e1075
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15
x-csrf-token: 5aee6a31-0100-4391-9f29-8631796e1075As you can see, we are sending the csrfToken both in the request cookie and in the x-csrf-token header.
The backend then validates by comparing cookie vs header:
const csrfCookie = req.cookies?.csrfToken
const csrfHeader = req.get('x-csrf-token')
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
c.securityError = 'CSRF_MISMATCH'
return false
}Since a malicious site cannot read the cookies set for your domain, and cannot arbitrarily send custom headers with an authentic request due to browser security policies (like the Same-Origin Policy and CORS restrictions), the attacker cannot retrieve and attach the correct CSRF token. As a result, the attack fails.