How to Decode and Check JWT Expiration Without a Library
JWT tokens are everywhere — authentication headers, API responses, OAuth flows. But you don't need jsonwebtoken, PyJWT, or any other library just to read an expiration date. The payload is Base64url-encoded plaintext. Here's how to decode it in any language in under 10 lines of code.
Understanding the JWT Structure
A JWT is three Base64url-encoded JSON objects separated by dots. The middle part (payload) contains the time claims — and it's readable without the secret key.
Important: decoding the payload does not require the secret key. The signature verifies authenticity — but reading the claims is always possible. This is by design: JWTs are encoded, not encrypted.
The Three JWT Time Claims
RFC 7519 defines three registered claims for time. All three use Unix seconds — never milliseconds.
All three use Unix seconds, not milliseconds. This is mandated by RFC 7519. If you store Date.now() (milliseconds) as exp, your token will appear valid for the next 500 years. Always use Math.floor(Date.now() / 1000).
Decode JWT in JavaScript — No Library
The payload segment is Base64url-encoded. JavaScript's native atob() handles Base64, with a small fix for the URL-safe alphabet (- and _ instead of + and /).
function decodeJWTPayload(token) {
// Split on dots and take the middle segment (payload)
const payloadB64 = token.split('.')[1];
// Convert Base64url → Base64 (replace URL-safe chars)
const base64 = payloadB64
.replace(/-/g, '+')
.replace(/_/g, '/');
// Pad to multiple of 4 (required by atob)
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
// Decode and parse JSON
return JSON.parse(atob(padded));
}
// Usage
const payload = decodeJWTPayload(token);
console.log(payload);
// → { sub: "user_123", iat: 1715429912, exp: 1715433512, nbf: 1715429912 }
function isExpired(token) {
const { exp } = decodeJWTPayload(token);
if (!exp) return false; // no expiry claim = token never expires
return Math.floor(Date.now() / 1000) > exp;
}
function timeLeft(token) {
const { exp } = decodeJWTPayload(token);
if (!exp) return null;
const seconds = exp - Math.floor(Date.now() / 1000);
if (seconds <= 0) return 'EXPIRED';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s remaining`;
}
function tokenAge(token) {
const { iat } = decodeJWTPayload(token);
if (!iat) return null;
return Math.floor(Date.now() / 1000) - iat; // seconds since issue
}
// Usage
isExpired(token); // → false
timeLeft(token); // → "42m 17s remaining"
tokenAge(token); // → 183 (seconds since issued)
Decode JWT in Python — No Library
import base64
import json
import time
def decode_jwt_payload(token: str) -> dict:
"""Decode JWT payload without verifying signature."""
# Get the middle segment
payload_b64 = token.split('.')[1]
# Convert Base64url → Base64 and add padding
padding = 4 - len(payload_b64) % 4
payload_b64 += '=' * (padding % 4)
payload_b64 = payload_b64.replace('-', '+').replace('_', '/')
return json.loads(base64.b64decode(payload_b64))
def is_expired(token: str) -> bool:
payload = decode_jwt_payload(token)
exp = payload.get('exp')
if exp is None:
return False # no expiry = never expires
return int(time.time()) > exp
def time_left(token: str) -> int | str:
payload = decode_jwt_payload(token)
exp = payload.get('exp')
if exp is None:
return 'no expiry'
seconds = exp - int(time.time())
return 'EXPIRED' if seconds <= 0 else seconds
# Usage
payload = decode_jwt_payload(token)
# → {'sub': 'user_123', 'iat': 1715429912, 'exp': 1715433512}
print(is_expired(token)) # → False
print(time_left(token)) # → 3247 (seconds remaining)
Decode JWT in Go — No Library
package main
import (
"encoding/base64"
"encoding/json"
"strings"
"time"
)
type JWTClaims struct {
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
Nbf int64 `json:"nbf"`
}
func DecodePayload(token string) (JWTClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return JWTClaims{}, fmt.Errorf("invalid JWT format")
}
// Base64url decode (no padding needed with RawURLEncoding)
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil { return JWTClaims{}, err }
var claims JWTClaims
err = json.Unmarshal(decoded, &claims)
return claims, err
}
func IsExpired(token string) bool {
claims, err := DecodePayload(token)
if err != nil || claims.Exp == 0 { return false }
return time.Now().Unix() > claims.Exp
}
func TimeLeft(token string) time.Duration {
claims, _ := DecodePayload(token)
return time.Unix(claims.Exp, 0).Sub(time.Now())
}
Decode JWT in PHP — No Library
function decodeJWTPayload(string $token): array {
$parts = explode('.', $token);
if (count($parts) !== 3) {
throw new InvalidArgumentException('Invalid JWT format');
}
// Base64url decode: replace URL-safe chars, add padding
$payload = $parts[1];
$payload = strtr($payload, '-_', '+/');
$payload = base64_decode(str_pad($payload, strlen($payload) + (4 - strlen($payload) % 4) % 4, '='));
return json_decode($payload, true);
}
function isExpired(string $token): bool {
$payload = decodeJWTPayload($token);
if (empty($payload['exp'])) return false;
return time() > $payload['exp'];
}
function timeLeft(string $token): int|string {
$payload = decodeJWTPayload($token);
if (empty($payload['exp'])) return 'no expiry';
$seconds = $payload['exp'] - time();
return $seconds <= 0 ? 'EXPIRED' : $seconds;
}
// Usage
$payload = decodeJWTPayload($token);
$expired = isExpired($token); // → false
$timeLeft = timeLeft($token); // → 3247
Paste your JWT — see exp, iat, nbf instantly
UnixLi's JWT decoder extracts all time claims, shows the exact expiration countdown, and converts every Unix timestamp to a human-readable date. No library, no signup, nothing leaves your browser.
Decode JWT in UnixLi →Creating JWT Timestamps Correctly
When issuing tokens, all time values must be Unix seconds. Here's the correct pattern for common scenarios:
const now = Math.floor(Date.now() / 1000); // Unix seconds ← critical
const payload = {
sub: 'user_123',
iat: now, // issued now
exp: now + (60 * 60), // expires in 1 hour
nbf: now, // valid from now
// Custom claims:
role: 'admin',
jti: crypto.randomUUID(), // unique token ID for revocation
};
// Common expiry durations (in seconds)
const DURATIONS = {
fifteenMin: 15 * 60, // access token — short lived
oneHour: 60 * 60, // typical session
oneDay: 24 * 60 * 60, // remember me
oneWeek: 7 * 24 * 60 * 60,
thirtyDays: 30 * 24 * 60 * 60, // refresh token
};
Common JWT Timestamp Mistakes
- Using
Date.now()instead ofMath.floor(Date.now() / 1000)— stores milliseconds asexp. Token appears valid until the year 58,000. - Not checking
nbf— accepting tokens before their "not before" time. A token withnbf = now + 300shouldn't be accepted for another 5 minutes. - Ignoring clock skew — servers in distributed systems may have clocks slightly out of sync. Allow 30–60 seconds of leeway when checking
exp. - Treating missing
expas expired — absence ofexpmeans the token never expires, not that it's immediately expired. These are different failure modes. - Checking expiration client-side only — this is a display check, not a security check. See the security note below.
function isValidNow(token, leewaySeconds = 30) {
const { exp, nbf } = decodeJWTPayload(token);
const now = Math.floor(Date.now() / 1000);
// Check expiration (with leeway for clock skew)
if (exp && now > exp + leewaySeconds) return false;
// Check not-before (with leeway)
if (nbf && now < nbf - leewaySeconds) return false;
return true;
}
Security Note — Client-Side vs Server-Side
Decoding ≠ verifying. Reading exp client-side is useful for UX (showing "session expires in 10 minutes") but it is not a security check. Anyone can craft a JWT with any payload. Token signature verification must always happen server-side with the secret key. Never grant access based solely on client-side expiration checks.
Use client-side JWT decoding for:
- Showing session expiry warnings in the UI
- Pre-emptively refreshing tokens before they expire
- Reading non-sensitive user data from claims (display name, role label)
- Debugging — understanding what a token contains
Quick Reference
| Claim | Meaning | Unit | RFC requirement |
|---|---|---|---|
exp | Expiration — must not accept after | Unix seconds | Optional but strongly recommended |
iat | Issued at — when was token created | Unix seconds | Optional |
nbf | Not before — must not accept before | Unix seconds | Optional |
jti | JWT ID — unique token identifier | String | Optional — used for revocation |
| Goal | JavaScript (one line) |
|---|---|
| Decode payload | JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))) |
| Is expired? | Date.now()/1000 > payload.exp |
| Seconds remaining | payload.exp - Math.floor(Date.now()/1000) |
| Token age (seconds) | Math.floor(Date.now()/1000) - payload.iat |
| Expiry as Date | new Date(payload.exp * 1000) |
| exp for +1 hour | Math.floor(Date.now()/1000) + 3600 |
Summary
You don't need a JWT library to read expiration claims. The payload is just Base64url-encoded JSON — decodable in three lines in any language. The only rules to remember: all JWT time claims are Unix seconds, use Math.floor(Date.now() / 1000) (not Date.now()) in JavaScript, and always verify signatures server-side for security-critical decisions.
For a visual JWT decoder with expiration countdown, try the UnixLi JWT decoder tab — paste any token and see all claims decoded with human-readable dates instantly.
Next: Unix seconds vs milliseconds — the most common timestamp confusion that leads to 1970 dates and 58,000-year tokens.