infrastructure

How to Decode a JWT Token: A DevOps Engineer's Guide

Learn how to decode JWT tokens with base64url, Python (PyJWT), and Node.js. Debug 401s, inspect claims, and understand why decoding is not verifying.

July 10, 2026·10 min read·
#jwt#authentication#devops#security#kubernetes

Introduction

If you run services in production, you have almost certainly stared at a JWT at 2 a.m. wondering why a request is being rejected with a 401 Unauthorized. JSON Web Tokens (JWTs) are the default currency of authentication in modern distributed systems — they carry identity between your API gateway, your microservices, and your service mesh. When auth breaks, the token is usually the first thing you need to open up and read.

This guide shows you how to decode a JWT the right way, from a quick command-line one-liner to production-grade Python and Node.js. More importantly, it explains the single most misunderstood fact about JWTs: decoding is not the same as verifying. Getting that distinction wrong is how security incidents happen.

What a JWT Actually Is

A JWT defined by RFC 7519 is just three Base64URL-encoded strings joined by dots:

header.payload.signature
  • Header — metadata: the signing algorithm (alg, e.g. RS256) and token type (typ).
  • Payload — the claims: who the subject is (sub), who issued it (iss), who it is for (aud), and when it expires (exp).
  • Signature — a cryptographic signature over the header and payload, produced with a secret (HMAC) or a private key (RSA/ECDSA).

A critical property: the header and payload are encoded, not encrypted. Anyone who holds the token can read every claim inside it. The signature does not hide the data — it only proves the token has not been tampered with. That is why you must never put secrets like passwords or API keys inside a JWT payload.

Decoding a JWT on the Command Line

Because each segment is Base64URL, you can decode a token with tools you already have on any Linux box. Base64URL differs from standard Base64 in two ways: it uses - and _ instead of + and /, and it strips trailing = padding. Here is a portable shell snippet that handles both:

# Decode the payload (second segment) of a JWT
decode_jwt() {
  local part=$(echo -n "$1" | cut -d. -f2)
  # restore base64url -> base64 and pad to a multiple of 4
  local pad=$(( (4 - ${#part} % 4) % 4 ))
  part="${part}$(printf '%*s' "$pad" | tr ' ' '=')"
  echo -n "$part" | tr '_-' '/+' | base64 -d | jq .
}

decode_jwt "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3ODMwMDAwMDB9.sig"

Piping the result through jq gives you nicely formatted claims. To read the header instead, change cut -d. -f2 to -f1. This is invaluable inside a CI pipeline or a debugging container where installing extra packages is painful.

Decoding a JWT in Python

For scripting and automation, PyJWT is the standard library. Install it with pip install pyjwt.

import jwt

token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3ODMwMDAwMDB9.sig"

# Decode WITHOUT verifying the signature - inspection only
claims = jwt.decode(token, options={"verify_signature": False})
print(claims)  # {'sub': '123', 'exp': 1783000000}

# Read the header separately
print(jwt.get_unverified_header(token))  # {'alg': 'HS256', 'typ': 'JWT'}

When you actually need to trust the token — not just read it — you verify the signature and pin the allowed algorithms:

try:
    claims = jwt.decode(
        token,
        key="your-shared-secret",
        algorithms=["HS256"],   # never trust the token's own alg blindly
        audience="my-service",
    )
except jwt.ExpiredSignatureError:
    print("Token has expired")
except jwt.InvalidTokenError as e:
    print(f"Invalid token: {e}")

Pinning algorithms explicitly defends against the classic alg: none and algorithm-confusion attacks, where an attacker swaps RS256 for HS256 to trick your server into verifying with the public key as an HMAC secret.

Decoding a JWT in Node.js

In the JavaScript ecosystem, jsonwebtoken is the go-to package (npm install jsonwebtoken).

const jwt = require('jsonwebtoken');
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3ODMwMDAwMDB9.sig';

// Decode only - returns the claims without checking the signature
const decoded = jwt.decode(token, { complete: true });
console.log(decoded.header);   // { alg: 'HS256', typ: 'JWT' }
console.log(decoded.payload);  // { sub: '123', exp: 1783000000 }

// Verify - throws if signature is bad or the token is expired
try {
  const claims = jwt.verify(token, 'your-shared-secret', { algorithms: ['HS256'] });
  console.log('Valid:', claims);
} catch (err) {
  console.error('Rejected:', err.message);
}

Note the same pattern as Python: decode() is a read operation, verify() is a trust operation. Never use decode() to make an authorization decision.

When You Need to Decode a JWT in a DevOps Workflow

Decoding tokens is a daily reality once you operate microservices. The most common situations:

  • Debugging a 401 in a microservice call. Service A calls Service B and gets rejected. Decode the token A is sending and check the aud claim — a mismatched audience is the number one cause of silent service-to-service auth failures.
  • Checking expiry and clock skew. Decode the exp and iat claims and compare them to the current epoch time. If your nodes' clocks drift, freshly issued tokens can appear expired. This ties directly into the reliability practices covered in our incident management runbook template.
  • Inspecting claims in a CI/CD pipeline. OIDC tokens issued to pipeline jobs (for example, to authenticate to a cloud provider without long-lived keys) carry claims like repository and ref. Decoding them helps you write correct trust policies.
  • Service-mesh and API-gateway auth. In Kubernetes, an Istio or Envoy sidecar validates JWTs before traffic reaches your pod. When requests are dropped, decoding the token tells you whether the iss matches the mesh's configured issuer. Getting this right is part of broader Kubernetes security best practices.

AI-Assisted Token Debugging

Modern AIOps platforms increasingly parse auth failures automatically. When a spike of 401s hits your gateway, an AI triage agent can decode the offending tokens, correlate a common expired iss or malformed aud across requests, and surface the root cause before a human opens a terminal. This is the same observability-driven pattern we explore in AI-powered observability — the agent reads the claims, you approve the fix.

Common JWT Decoding Errors (and How to Fix Them)

Decoding looks simple until a token refuses to parse or a verifier rejects a token you are certain is valid. These are the failures you will actually hit in production, and how to diagnose each one fast.

Base64 Padding Errors

The error looks like Invalid base64-encoded string or Incorrect padding. It happens because JWTs use Base64URL, which strips the trailing = characters that standard Base64 decoders expect. If you feed a raw segment straight into base64 -d or Python's base64.b64decode, it chokes on the missing padding.

The fix is to re-add padding before decoding. In Python, PyJWT handles this for you, but if you decode by hand:

import base64, json

def decode_segment(seg: str) -> dict:
    padding = "=" * (-len(seg) % 4)          # restore missing padding
    raw = base64.urlsafe_b64decode(seg + padding)
    return json.loads(raw)

Use urlsafe_b64decode, not b64decode — the former understands the - and _ alphabet. The same rule is why the shell snippet earlier ran tr '_-' '/+' before piping to base64 -d.

The alg: none and Algorithm-Confusion Trap

This is a security bug, not just a parsing error. Early JWT libraries honored an alg header of none, meaning "no signature required." An attacker strips the signature, sets alg to none, and your verifier accepts a forged token. A related attack swaps RS256 for HS256 so the verifier uses the public RSA key as an HMAC secret — which the attacker also knows.

The defense is always the same: pin the algorithm on the verifier side and never derive it from the token.

# WRONG - trusts whatever the token declares
jwt.decode(token, key, algorithms=jwt.get_unverified_header(token)["alg"])

# RIGHT - the server dictates the algorithm
jwt.decode(token, key, algorithms=["RS256"])

A modern PyJWT or jsonwebtoken release rejects alg: none by default, but only if you pass an explicit algorithms allowlist. Never omit it.

Clock Skew on exp and nbf

A token that is genuinely valid gets rejected with ExpiredSignatureError or ImmatureSignatureError because the verifying node's clock disagrees with the issuer's. The exp (expiry) and nbf (not-before) claims are absolute Unix timestamps, so even a few seconds of drift on a node without NTP can reject fresh tokens or briefly honor stale ones.

Decode the token, read exp and nbf, and compare them to the node's own date +%s. If they are close to the boundary, the real fix is NTP — but most libraries also let you allow a small tolerance:

jwt.decode(token, key, algorithms=["HS256"], leeway=30)  # 30s of skew tolerance

Keep the leeway small (10–30 seconds). A large window weakens the security guarantee that expiry is supposed to provide. Recurring skew is a classic finding in postmortems, which is exactly why it belongs in your runbook checklist.

Invalid Signature on a Correct-Looking Token

The claims decode cleanly but verify throws InvalidSignatureError. The usual causes: the wrong key or secret, a key that has rotated, or an encoding mismatch (verifying against the base64 of a secret instead of its raw bytes). When the payload looks right but the signature fails, stop debugging the token and start debugging your key material — you are almost always comparing against the wrong key.

The Security Caveat You Cannot Skip

Two rules protect you from turning a debugging session into an incident.

Decoding is not verifying. Reading the claims out of a token tells you what it says, not whether it is authentic. An attacker can craft a token with "sub": "admin" and any expiry they like. Only signature verification against a trusted key proves the token was issued by someone you trust. Every authorization decision in your code must go through verify, never decode. The OWASP JWT guidance treats this as a primary control.

Never paste a production token into a random website. A live JWT is a bearer credential — whoever holds it can act as that user until it expires. Pasting a production token into an unknown online decoder ships that credential to a third-party server you do not control. It may be logged, cached, or replayed. Treat a leaked token exactly as you would treat a leaked password: as an incident that burns into your error budget and demands rotation.

The safe alternative is a decoder that runs entirely in your browser, where the token is parsed in local JavaScript and never sent over the network.

Decode a JWT Safely, Right Now

When you just need to crack open a token and read its claims, use our free JWT Decoder. It decodes the header and payload instantly in your browser — the token never leaves your device, so it is safe to use even with sensitive tokens. Paste, read the claims, check the exp, and get back to shipping.

Conclusion

A JWT is three Base64URL segments carrying a header, a set of claims, and a signature. You can decode the readable parts with a shell one-liner, PyJWT, or Node's jsonwebtoken — all useful when you are chasing a 401 through a mesh of microservices. But keep the golden rule front of mind: decoding reveals what a token claims, and only verification proves it is real. Read tokens freely for debugging, verify them always for trust, and never hand a live token to a server you do not own.

References

#jwt#authentication#devops#security#kubernetes
D
DevToCashAuthor

Senior DevOps/SRE Engineer · 10+ years · Professional Trader (IDX, Crypto, US Equities)

I write about real infrastructure patterns and trading strategies I use in production and in live markets. No courses, no affiliate hype — just documentation of what actually works.

More about me →