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.

exp
Expiration Time
The token must not be accepted after this time. The most commonly checked claim.
iat
Issued At
When the token was created. Useful to calculate token age or detect old tokens being reused.
nbf
Not Before
The token must not be accepted before this time. Used for delayed activation or future-dated tokens.
⚠️

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 /).

JavaScript — decode payload
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 }
JavaScript — check expiration
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

Python
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

Go
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

PHP
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:

JavaScript — create JWT payload with correct timestamps
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 of Math.floor(Date.now() / 1000) — stores milliseconds as exp. Token appears valid until the year 58,000.
  • Not checking nbf — accepting tokens before their "not before" time. A token with nbf = now + 300 shouldn'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 exp as expired — absence of exp means 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.
JavaScript — check with clock skew tolerance
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

ClaimMeaningUnitRFC requirement
expExpiration — must not accept afterUnix secondsOptional but strongly recommended
iatIssued at — when was token createdUnix secondsOptional
nbfNot before — must not accept beforeUnix secondsOptional
jtiJWT ID — unique token identifierStringOptional — used for revocation
GoalJavaScript (one line)
Decode payloadJSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')))
Is expired?Date.now()/1000 > payload.exp
Seconds remainingpayload.exp - Math.floor(Date.now()/1000)
Token age (seconds)Math.floor(Date.now()/1000) - payload.iat
Expiry as Datenew Date(payload.exp * 1000)
exp for +1 hourMath.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.