JWT Error: JsonWebTokenError, TokenExpiredError, dan Cara Fixnya
JWT Error: JsonWebTokenError, TokenExpiredError, dan Cara Fixnya
JWT (JSON Web Token) adalah standar autentikasi yang dipakai hampir semua aplikasi modern. Tapi error JWT bisa sangat membingungkan — terutama karena pesan error-nya sering tidak menjelaskan mengapa token tidak valid. Artikel ini membahas semua error JWT yang umum dan cara mengatasinya.
1. Jenis-jenis JWT Error
JsonWebTokenError: invalid signature
// Penyebab paling umum:
// 1. JWT_SECRET berbeda antara yang dipakai saat sign dan verify
// 2. Token dimodifikasi setelah dibuat
// 3. Token dari environment berbeda (dev token dipakai di production)
// Debug: cek secret yang dipakai
console.log('Secret length:', process.env.JWT_SECRET?.length);
// Jika undefined atau berbeda antara service → ini penyebabnya
// Fix: pastikan JWT_SECRET sama di semua service yang perlu verify token
// Gunakan secret yang sama dari environment variable, bukan hardcode
// ❌ Jangan hardcode secret
const token = jwt.sign(payload, 'mysecret123');
// ✅ Selalu dari environment variable
const token = jwt.sign(payload, process.env.JWT_SECRET);
TokenExpiredError: jwt expired
// Token sudah melewati waktu expiry-nya
// Ini adalah behavior yang BENAR — bukan bug
// Cara handle dengan benar:
function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
if (err.name === 'TokenExpiredError') {
// Token expired — minta user refresh token atau login ulang
throw new Error('SESSION_EXPIRED');
}
if (err.name === 'JsonWebTokenError') {
// Token tidak valid — kemungkinan dimanipulasi
throw new Error('INVALID_TOKEN');
}
throw err;
}
}
// Di middleware Express:
export function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token tidak ditemukan' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Sesi login telah berakhir',
code: 'TOKEN_EXPIRED' // Frontend bisa cek code ini untuk auto-refresh
});
}
return res.status(401).json({ error: 'Token tidak valid' });
}
}
JsonWebTokenError: jwt malformed
// Token tidak dalam format JWT yang valid (header.payload.signature)
// Penyebab umum:
// 1. Frontend mengirim token tanpa prefix "Bearer "
// 2. Token terpotong saat disimpan (localStorage limit, cookie size)
// 3. Token mengandung karakter yang tidak valid
// Cek format token:
const authHeader = req.headers.authorization;
console.log('Auth header:', authHeader);
// Harus: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
// Fix di frontend — pastikan format pengiriman benar:
fetch('/api/users', {
headers: {
'Authorization': `Bearer ${token}`, // Perhatikan spasi setelah "Bearer"
}
});
// Cek apakah token tersimpan dengan benar:
const stored = localStorage.getItem('token');
console.log('Token starts with eyJ:', stored?.startsWith('eyJ'));
// JWT selalu dimulai dengan "eyJ" (base64 dari {"alg":...)
NotBeforeError: jwt not active
// Token punya claim "nbf" (not before) yang belum tercapai
// Jarang terjadi tapi bisa muncul jika ada clock skew antar server
// Fix: tambahkan toleransi waktu saat verify
jwt.verify(token, process.env.JWT_SECRET, {
clockTolerance: 30 // Toleransi 30 detik untuk clock skew
});
2. Implementasi Refresh Token yang Benar
// Sistem dua token: access token (short-lived) + refresh token (long-lived)
// Generate token pair
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET, // Secret berbeda untuk refresh token!
{ expiresIn: '7d' } // Long-lived
);
return { accessToken, refreshToken };
}
// Endpoint refresh token
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token tidak ditemukan' });
}
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Validasi: pastikan ini memang refresh token, bukan access token
if (payload.type !== 'refresh') {
return res.status(401).json({ error: 'Token tidak valid' });
}
// Validasi: cek apakah refresh token masih ada di database
// (untuk bisa invalidate saat logout)
const stored = await RefreshToken.findOne({
token: refreshToken,
userId: payload.userId
});
if (!stored) {
return res.status(401).json({ error: 'Refresh token sudah tidak valid' });
}
// Generate access token baru
const newAccessToken = jwt.sign(
{ userId: payload.userId, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Sesi login telah berakhir, silakan login ulang',
code: 'REFRESH_TOKEN_EXPIRED'
});
}
res.status(401).json({ error: 'Token tidak valid' });
}
});
3. Auto-Refresh di Frontend
// Interceptor Axios untuk auto-refresh token
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
// Request interceptor: tambahkan access token ke setiap request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor: handle 401 dengan auto-refresh
let isRefreshing = false;
let failedQueue = [];
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 &&
error.response?.data?.code === 'TOKEN_EXPIRED' &&
!originalRequest._retry) {
if (isRefreshing) {
// Tunggu refresh yang sedang berjalan
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
localStorage.setItem('accessToken', data.accessToken);
// Retry semua request yang gagal
failedQueue.forEach(({ resolve }) => resolve(data.accessToken));
failedQueue = [];
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh gagal → logout user
failedQueue.forEach(({ reject }) => reject(refreshError));
failedQueue = [];
localStorage.clear();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
4. Checklist Keamanan JWT
| Item | Status |
|---|---|
| JWT_SECRET minimal 32 karakter random | ☐ |
| Access token expiry pendek (15 menit - 1 jam) | ☐ |
| Refresh token disimpan di database (bisa di-invalidate) | ☐ |
| Secret berbeda untuk access dan refresh token | ☐ |
| Token type dicek saat verify (access vs refresh) | ☐ |
| Error handling membedakan expired vs invalid | ☐ |
| Logout menghapus refresh token dari database | ☐ |
| Access token disimpan di memory, bukan localStorage (untuk keamanan XSS) | ☐ |
Kesimpulan
JWT error hampir selalu bisa didiagnosis dengan melihat nama error-nya: TokenExpiredError berarti token sudah kadaluarsa (normal), JsonWebTokenError: invalid signature berarti secret tidak cocok, dan jwt malformed berarti format token salah. Implementasikan sistem refresh token dengan benar dan tambahkan auto-refresh di frontend untuk pengalaman pengguna yang mulus tanpa harus login ulang setiap 15 menit.