How to Decode a JWT Token Without Any Library (Base64 Decode + JSON Parse)

I was debugging a JWT issue on a production server with no package manager access. The only tools available were the shell, Python 3, and curl. No jsonwebtoken library, no PyJWT, no jq with JWT extensions.

I typed this in a terminal:

echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 -d 2>/dev/null || echo 'fix padding'

And realized: that is all JWT decoding is. You do not need a library. You do not need a package. You need three things:

  1. Split the token on dots
  2. Base64URL-decode each segment
  3. Parse the result as JSON

That is it. The entire "decode" operation that every JWT library performs is exactly this. The libraries add value on top for verification, claim validation, and algorithm negotiation —but the decode itself is trivial.

This article walks through how to decode JWTs from scratch in multiple languages, explains the Base64URL encoding details that trip people up, and shows how to build your own JWT decoder in under 20 lines of code.


JWT Structure in 10 Seconds

Every JWT has three parts separated by dots:

header.payload.signature

Each part is Base64URL-encoded JSON (or binary data for the signature).

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

To decode, you:

  1. Take the first segment —that is the header
  2. Take the second segment —that is the payload
  3. Base64URL-decode each one
  4. Parse as JSON

The third segment —the signature —is binary data that you only need if you are verifying. For decoding alone, ignore it.


The Complete Decode Function (JavaScript)

function decodeJWT(token) {
  try {
    // Split the token into parts
    const parts = token.split('.');

    if (parts.length !== 3) {
      throw new Error('Invalid JWT: must have 3 segments');
    }

    // Decode header
    const headerJson = atob(base64urlToBase64(parts[0]));
    const header = JSON.parse(headerJson);

    // Decode payload
    const payloadJson = atob(base64urlToBase64(parts[1]));
    const payload = JSON.parse(payloadJson);

    // Return decoded token
    return {
      header,
      payload,
      signature: parts[2],
    };
  } catch (err) {
    throw new Error('Failed to decode JWT: ' + err.message);
  }
}

// Base64URL uses - instead of +, _ instead of /
// and does not include padding = characters
function base64urlToBase64(base64url) {
  let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');

  // Add padding if needed
  while (base64.length % 4 !== 0) {
    base64 += '=';
  }

  return base64;
}

// Usage
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

const decoded = decodeJWT(token);
console.log('Header:', decoded.header);
console.log('Payload:', decoded.payload);

// Output:
// Header: { alg: 'HS256', typ: 'JWT' }
// Payload: { sub: '1234567890', name: 'John Doe', iat: 1516239022 }

The Complete Decode Function (Python)

import base64
import json
from typing import Dict, Any

def decode_jwt(token: str) -> Dict[str, Any]:
    """Decode a JWT token without any external library."""
    try:
        parts = token.split(".")

        if len(parts) != 3:
            raise ValueError("Invalid JWT: must have 3 segments")

        def decode_segment(segment: str) -> Dict:
            # Fix Base64URL -> Base64
            padded = segment.replace("-", "+").replace("_", "/")

            # Add padding
            padding = 4 - len(padded) % 4
            if padding != 4:
                padded += "=" * padding

            # Decode and parse
            decoded_bytes = base64.b64decode(padded)
            return json.loads(decoded_bytes)

        return {
            "header": decode_segment(parts[0]),
            "payload": decode_segment(parts[1]),
            "signature": parts[2],
        }

    except Exception as err:
        raise ValueError(f"Failed to decode JWT: {err}")


# Usage
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"

decoded = decode_jwt(token)
print("Header:", decoded["header"])
print("Payload:", decoded["payload"])

The Complete Decode Function (Command Line)

#!/bin/bash
# decode-jwt.sh —decode a JWT without any library

decode_base64url() {
  local input="$1"
  local length=${#input}

  # Convert Base64URL to Base64
  local base64=$(echo "$input" | tr '-_' '+/')

  # Add padding
  local padding=$(( (4 - length % 4) % 4 ))
  for ((i=0; i<padding; i++)); do
    base64="${base64}="
  done

  echo "$base64" | base64 -d 2>/dev/null || echo "Decode failed"
}

token="$1"

if [ -z "$token" ]; then
  echo "Usage: $0 <jwt_token>"
  exit 1
fi

# Split on dots
IFS='.' read -r header payload signature <<< "$token"

echo "=== Header ==="
decode_base64url "$header" | python3 -m json.tool 2>/dev/null || decode_base64url "$header"

echo ""
echo "=== Payload ==="
decode_base64url "$payload" | python3 -m json.tool 2>/dev/null || decode_base64url "$payload"

echo ""
echo "=== Signature (hex) ==="
# The signature is binary —show it as hex
echo "$token" | awk -F'.' '{print $3}' | tr '-_' '+/' | base64 -d 2>/dev/null | xxd | head -5

Save this as decode-jwt.sh and run:

chmod +x decode-jwt.sh
./decode-jwt.sh 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'

The Complete Decode Function (Rust)

Rust developers also need to handle JWTs without pulling in a crate sometimes:

use serde_json::Value;
use base64::{Engine as _, engine::general_purpose};

#[derive(Debug)]
struct DecodedJWT {
    header: Value,
    payload: Value,
    signature: String,
}

fn decode_jwt(token: &str) -> Result<DecodedJWT, Box<dyn std::error::Error>> {
    let parts: Vec<&str> = token.split('.').collect();
    if parts.len() != 3 {
        return Err("Invalid JWT: must have 3 segments".into());
    }

    fn decode_segment(encoded: &str) -> Result<Value, Box<dyn std::error::Error>> {
        // Base64URL to Base64
        let base64 = encoded
            .replace('-', "+")
            .replace('_', "/");

        // Add padding
        let padded = format!("{:padding$}", base64, padding = (4 - base64.len() % 4) % 4);

        let decoded = general_purpose::STANDARD.decode(&padded)?;
        Ok(serde_json::from_slice(&decoded)?)
    }

    Ok(DecodedJWT {
        header: decode_segment(parts[0])?,
        payload: decode_segment(parts[1])?,
        signature: parts[2].to_string(),
    })
}

Understanding Base64URL

The critical detail that makes JWT decoding work is the Base64URL encoding scheme. It is a minor modification of standard Base64 that makes the output safe for URLs and filenames.

Standard Base64

ByteBase64
0x3E (62)+
0x3F (63)/
Padding=

Base64URL (RFC 4648 §5)

ByteBase64URL
0x3E (62)- (minus)
0x3F (63)_ (underscore)
PaddingUsually omitted

The simple character substitution:

// Base64URL →Base64 (for decoding)
function base64urlToBase64(input) {
  return input
    .replace(/-/g, '+')
    .replace(/_/g, '/');
}

// Base64 →Base64URL (for encoding)
function base64ToBase64url(input) {
  return input
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

The padding is optional in Base64URL because the decoder can infer the missing bytes from the length. Most JWT libraries strip the padding. When decoding, you must add it back.

The Padding Calculation

Base64 encodes 3 bytes of input into 4 characters of output. If the input length is not a multiple of 3, padding = characters are added.

Input bytes:      3    → 4 Base64 chars  → 0 padding
Input bytes:      2    → 4 Base64 chars  → 1 padding (=)
Input bytes:      1    → 4 Base64 chars  → 2 padding (==)

To calculate padding when decoding:

function addBase64Padding(input) {
  const remainder = input.length % 4;
  if (remainder === 0) return input;
  return input + '='.repeat(4 - remainder);
}

For a deeper explanation of why JWT specifically uses Base64URL and how it differs from standard Base64, see the How JWT Uses Base64URL Encoding guide.


Building a Full JWT Decoder from Scratch

Let me put it all together into a complete JWT decoder that also validates expiration and shows human-readable timestamps:

function jwtDecoder(token) {
  function b64url(input) {
    let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
    while (b64.length % 4) b64 += '=';
    return JSON.parse(atob(b64));
  }

  const parts = token.split('.');
  if (parts.length !== 3) {
    return { error: 'Invalid JWT format —expected 3 dot-separated segments' };
  }

  const header = b64url(parts[0]);
  const payload = b64url(parts[1]);
  const now = Math.floor(Date.now() / 1000);

  return {
    header,
    payload,
    meta: {
      isValid: payload.exp ? payload.exp > now : 'No expiration claim',
      isExpired: payload.exp ? payload.exp <= now : undefined,
      expiresAt: payload.exp
        ? new Date(payload.exp * 1000).toISOString()
        : undefined,
      issuedAt: payload.iat
        ? new Date(payload.iat * 1000).toISOString()
        : undefined,
      secondsRemaining: payload.exp
        ? Math.max(0, payload.exp - now)
        : undefined,
      algorithm: header.alg,
      tokenType: header.typ,
    },
  };
}

const result = jwtDecoder('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTl9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c');

console.log(JSON.stringify(result, null, 2));
// {
//   "header": { "alg": "HS256", "typ": "JWT" },
//   "payload": { "sub": "1234567890", "name": "John Doe", "iat": 1516239022, "exp": 9999999999 },
//   "meta": {
//     "isValid": true,
//     "isExpired": false,
//     "expiresAt": "2286-11-21T01:46:39.000Z",
//     "issuedAt": "2018-01-18T01:30:22.000Z",
//     "secondsRemaining": 9999999999,
//     "algorithm": "HS256",
//     "tokenType": "JWT"
//   }
// }

This is essentially what every JWT decode tool does under the hood. Including the JWT Decoder —it is just Base64URL decoding, JSON parsing, and a few claim checks.


Decoding vs. Verification —A Critical Distinction

Decoding a JWT is the easy part. Verification is where the security lives.

Decoding tells you what the token claims to contain. Verification confirms those claims are trustworthy.

// Decode —anyone can do this
const decoded = JSON.parse(atob(token.split('.')[1]));
// Returns whatever the token claims, even if forged

// Verify —requires the secret key
const crypto = require('crypto');

function verifyHS256(token, secret) {
  const parts = token.split('.');
  const data = parts[0] + '.' + parts[1];
  const signature = parts[2];

  // Recompute the signature
  const expected = crypto
    .createHmac('sha256', secret)
    .update(data)
    .digest('base64url');

  // Constant-time compare
  if (signature !== expected) {
    throw new Error('Signature mismatch —token has been tampered with');
  }

  return JSON.parse(Buffer.from(parts[1], 'base64url'));
}

This is covered in detail in JWT Decode vs Verify, but the key takeaway is: decoding without verifying is like opening an envelope and trusting the letter inside —anyone could have written it.


When to Decode Without a Library

There are legitimate scenarios where decoding a JWT without a library is the right call:

Debugging on a Restricted Server

You SSH into a production box that has no internet access and only base utilities. You need to check why a token is being rejected. The command-line decoder above will save you.

Learning and Understanding

When you decode a JWT by hand, you understand what the library is actually doing. This knowledge helps you debug issues faster and avoid security mistakes.

Building Custom Tooling

You might want to build a CI/CD pipeline step that checks JWT claims, or a monitoring tool that logs token metadata. A five-line decode function is all you need.

Quick One-Off Inspections

# One-liner to decode a JWT payload in a shell
echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ' | tr '-_' '+/' | base64 -d 2>/dev/null; echo

# Python one-liner
python3 -c "import base64, json; print(json.loads(base64.urlsafe_b64decode('eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ' + '===')))"

But for regular development and debugging, using a purpose-built tool is faster. The JWT Decoder wraps all of this logic —including padding handling, timestamp conversion, and claim validation —into a single paste operation. I use it daily to inspect tokens during development, debug auth flows, and validate JWTs from third-party identity providers.


FAQ

Can I decode a JWT without any library?

Yes. A JWT is three Base64URL-encoded segments joined by dots. Split on ., Base64URL-decode the first two segments, and parse the result as JSON. That is the entire decode operation.

What is the difference between Base64 and Base64URL?

Base64URL replaces + with - and / with _ to make the output safe for URLs and filenames. It also typically omits the = padding. When decoding, you must convert back to standard Base64 and re-add padding.

Why does Base64URL need padding when decoded?

Base64 encodes 3 bytes into 4 characters. If the input length is not a multiple of 3, padding (=) is added. Base64URL tokens often strip the padding. You must add it back before the decoder can process the string.

Do I need the secret key to decode a JWT?

No. Decoding only requires Base64URL decoding and JSON parsing —both are public operations. The secret key is only needed for verification (checking the signature).

Is decoding a JWT without verification useful?

Yes, for debugging, inspection, and learning. You can check the expiration time, see what claims are in the token, and understand why verification might be failing. Just do not use decoded-but-unverified data for authorization decisions.

How does the JWT Decoder tool work?

It applies the exact same logic shown in this article: split the token on dots, Base64URL-decode the header and payload, parse as JSON, and display the results with added information like expiration status and timestamp conversions.

Can I build my own JWT decoder?

Absolutely. The core logic is 10—5 lines of code in any language: split on ., character substitution for Base64URL to Base64, padding fix, Base64 decode, JSON parse. The examples in this article give you a complete implementation.