By AndyPublished

Decode a JWT From the Command Line: Bash, jq, PowerShell and JWT CLI Tools

The shortest reliable way to decode a JWT in a terminal is jq 1.7 on its own: jq -R 'split(".") | .[0:2] | map(gsub("-";"+") | gsub("_";"/") | @base64d | fromjson)' <<< "$TOKEN". It prints the header and payload as JSON. The popular cut -d. -f2 | base64 -d trick fails on many real tokens, because JWT segments use the base64url alphabet and drop the = padding.

Below are tested versions for bash, PowerShell, Python and Node.js, followed by two dedicated tools, the Rust jwt-cli and smallstep's step, which can also verify signatures. Decoding only reads the token; it proves nothing about who issued it.

Why the Naive base64 -d Approach Fails

A JWT is three base64url segments joined by dots (RFC 7515 §2). Base64url differs from ordinary Base64 in two ways: it uses - and _ in place of + and /, and it omits trailing = padding. GNU base64 -d understands neither. Here is a real payload segment that contains an underscore:

$ echo "$TOKEN" | cut -d. -f2 | base64 -d
{"sub":"user-123","name":"Ada Lovelace","role":"admin","emoji_free":"übase64: invalid input

The output stops at the first _. When a segment's length is not a multiple of four, the last few bytes are also lost or rejected, depending on the implementation. Whether a given token happens to work is luck, which is why a one-liner that works on one token breaks on the next. The header, payload and signature guide explains the encoding in full.

How to Decode a JWT in Bash

This function translates the alphabet, restores the padding and pretty-prints with jq. Pass 1 for the header; the payload is the default.

jwt_decode() {
  local seg
  seg=$(printf '%s' "$1" | cut -d. -f"${2:-2}" | tr '_-' '/+')
  case $(( ${#seg} % 4 )) in
    2) seg="$seg==" ;;
    3) seg="$seg=" ;;
  esac
  printf '%s' "$seg" | base64 --decode | jq .
}

jwt_decode "$TOKEN" 1   # header
jwt_decode "$TOKEN"     # payload
jwt_decode "$TOKEN" | jq -r '.exp | todate'
# 2026-09-21T15:13:20Z

It works in bash and zsh. The function was tested on a token whose payload needed two padding characters and contained both _ and multi-byte UTF-8.

macOS: base64 -D vs -d

The long option --decode is accepted by both GNU coreutils and the macOS base64, which is why the function uses it. The short flag is where scripts break: GNU uses -d, older macOS releases accepted only -D, and recent macOS accepts both. For timestamps without jq, GNU date -u -d @1790003600 and BSD/macOS date -u -r 1790003600 do the same conversion.

Decode a JWT With jq Alone

jq's @base64d filter decodes Base64 and, in jq 1.7, accepts input without padding, so only the alphabet needs translating. Running it with -R reads the token as a raw string:

jq -R 'split(".") | .[0:2] | map(gsub("-";"+") | gsub("_";"/") | @base64d | fromjson)' <<< "$TOKEN"

# Only the payload, with readable dates
jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson
       | .exp |= todate | .iat |= todate' <<< "$TOKEN"

The second form fails if a token has no iat; drop that part of the filter when needed. If you are on jq 1.6 and see decoding errors, use the bash function instead, which pads explicitly.

Decode a JWT in PowerShell

.NET's Convert.FromBase64String is strict about both alphabet and padding, so the same two fixes are needed. This function was tested in PowerShell 7.6 and uses nothing that Windows PowerShell 5.1 lacks:

function ConvertFrom-Jwt([string]$Token) {
  foreach ($part in $Token.Split('.')[0..1]) {
    $s = $part.Replace('-', '+').Replace('_', '/')
    switch ($s.Length % 4) { 2 { $s += '==' } 3 { $s += '=' } }
    [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)) |
      ConvertFrom-Json | ConvertTo-Json -Depth 10
  }
}

ConvertFrom-Jwt $env:TOKEN

# Convert exp to a date
[DateTimeOffset]::FromUnixTimeSeconds(1790003600).UtcDateTime

ConvertTo-Json -Depth 10 matters: the default depth truncates nested claims such as Keycloak's realm_access or Entra ID's nested objects.

Python and Node.js One-Liners

# Python 3: header and payload, padding restored with -len(s) % 4
python3 -c "import sys,json,base64; [print(json.dumps(json.loads(base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))), indent=2, ensure_ascii=False)) for s in sys.argv[1].split('.')[:2]]" "$TOKEN"

# Node.js 16+: Buffer understands base64url directly, no padding needed
node -e 'for (const s of process.argv[1].split(".").slice(0, 2)) console.log(JSON.parse(Buffer.from(s, "base64url")))' "$TOKEN"

These are useful on servers where jq is not installed but a runtime is. For decoding inside application code rather than a shell, see decoding a JWT in JavaScript, Python and Go.

Decode Tokens From curl Output and Log Files

In practice the token is rarely sitting in a variable. It arrives in a JSON response from a token endpoint, or it is buried in a log line. Both cases compose with the bash function above:

# Straight from a token endpoint response
TOKEN=$(curl -s -X POST https://auth.example.com/oauth/token \
  -d grant_type=client_credentials -u "$CLIENT_ID:$CLIENT_SECRET" | jq -r .access_token)
jwt_decode "$TOKEN"

# Every JWT-shaped string in a log file, summarised one per line
grep -oE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*' app.log |
  while read -r t; do jwt_decode "$t" | jq -c '{sub, exp}'; done

The pattern relies on the fact that a compact JSON header starting with {" always encodes to eyJ. It also matches some non-JWT strings with that prefix, so treat the output as candidates. If a log contains tokens at all, that is worth fixing: Authorization headers should be redacted before they are written. The curl and Postman guide shows how to send the token back to an API once you have inspected it.

jwt-cli: The Rust JWT CLI (brew, cargo, Linux)

jwt-cli by Mike Engel is the most widely used dedicated tool. The crate and package are called jwt-cli; the installed binary is jwt. Version 6.2.0 is current on crates.io and Homebrew at the time of writing.

# Install (pick one)
brew install jwt-cli          # Homebrew formula; the README also lists mike-engel/jwt-cli/jwt-cli
cargo install jwt-cli         # installs to ~/.cargo/bin
scoop install jwt-cli         # Windows
sudo pacman -S jwt-cli        # Arch Linux
# Other Linux: prebuilt binaries on the GitHub releases page

# Decode (header and claims, pretty-printed)
jwt decode "$TOKEN"

# JSON output with exp/iat/nbf rendered as ISO 8601 dates
jwt decode --json --date "$TOKEN"

# Read the token from stdin
curl -s https://auth.example.com/token ... | jq -r .access_token | jwt decode -

# Validate an HS256 signature too (secret from a file, or base64 bytes)
jwt decode --secret @secret.txt "$TOKEN"
jwt decode --secret b64:c2VjcmV0... "$TOKEN"

Without --secret, jwt decode only decodes. With one, it also checks the signature and expiry: a wrong secret prints "The JWT provided has an invalid signature" and exits with status 1, and an expired token is rejected unless you pass --ignore-exp. For RSA and EC keys, --secret @public.pem reads the key from a file; the underlying library requires PKCS#8 for EC private keys when encoding. The same binary can also create tokens with jwt encode, handy for local tests.

What about "jwt cli npm"?

The npm package named jwt-cli is a different, smaller project (version 2.0.0, last published in 2022) that also installs a command called jwt. Installing both puts two unrelated jwt binaries on your PATH; check jwt --version if the flags above do not work. If you already have Node.js, the one-liner in the previous section avoids the extra dependency.

step CLI: Inspect and Verify

smallstep's step (installed with brew install step, winget install Smallstep.step or a Linux package) has a crypto jwt subcommand. Inspecting requires --insecure as a deliberate reminder that nothing is being verified:

# Decode without verifying
echo "$TOKEN" | step crypto jwt inspect --insecure

# Verify signature, issuer and audience with a PEM public key
echo "$TOKEN" | step crypto jwt verify --key public.pem \
  --iss https://issuer.example --aud api

# Or against a JWKS file
echo "$TOKEN" | step crypto jwt verify --jwks jwks.json \
  --iss https://issuer.example --aud api

verify insists on --iss and --aud unless you pass --subtle, and --no-exp-check lets you examine an expired token. That strictness makes it a good model of what a real verifier should check; see the JWT verification guide for the full list.

JWT CLI Options Compared

ToolInstallDecodesVerifies
bash + base64 + jqUsually preinstalled (jq via package manager)YesNo
jq only (1.7)apt, dnf, brew, wingetYesNo
PowerShellBuilt into Windows; pwsh on Linux/macOSYesNo
Python / Node.js one-linerStandard library onlyYesNo
jwt-cli (Rust, binary "jwt")cargo install jwt-cli, brew, scoop, pacmanYesHS* secret, or a key file
step CLIbrew install step, winget, scoopYes (inspect --insecure)Yes (verify with --key or --jwks)

Decode JWTs Locally Without Leaking Them

A JWT is a bearer credential until it expires. Anyone who copies it from your terminal, logs or history can use it. A few habits keep that from happening:

  • ·Keep tokens out of shell history. Pasting TOKEN=eyJ... writes the token to ~/.bash_history or ~/.zsh_history. Read it without echoing instead: read -rs TOKEN, then paste. In bash, HISTCONTROL=ignorespace also skips any command typed with a leading space; zsh has setopt HIST_IGNORE_SPACE.
  • ·PowerShell keeps history too. PSReadLine saves commands to the file shown by (Get-PSReadLineOption).HistorySavePath. Load tokens from an environment variable or file rather than typing them.
  • ·Watch process listings. A token passed as an argument is visible to other users via ps while the command runs. Piping it on stdin (jwt decode -, step crypto jwt inspect) avoids that.
  • ·Never paste production tokens into tools that upload them. All the commands on this page run locally. In the browser, the jwtdecode.app decoder also decodes entirely client-side, which decoding without a server explains.
⚠
Decoding is not validation. Every method above will happily print a token with a forged signature or a past exp. Use decoded output for debugging only, and make authorisation decisions from a verified token. The decoder vs validator page covers the difference.

Summary

  • ·JWT segments are base64url without padding; translate -_ to +/ and pad to a multiple of four before using base64 --decode or FromBase64String.
  • ·jq 1.7's @base64d gives a one-liner; Python and Node.js one-liners work where jq is missing.
  • ·For a dedicated tool, the Rust jwt-cli (binary jwt) decodes, validates and encodes; step crypto jwt inspects and strictly verifies.
  • ·Keep tokens out of shell history and process listings, and never treat decoded output as verified.
Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder