A CLI tool that decodes and pretty-prints JSON Web Tokens (JWTs) and JSON Web Encryption (JWE) tokens with syntax-highlighted JSON output.
- Decode any JWT and display its header, payload, and signature
- Decode and decrypt JWE tokens with automatic format detection
- JWS signature verification with
--keyflag - Supports RSA, ECDSA, Ed25519, and HMAC signature algorithms
- Key loading from PEM/DER keys, X.509 certificates, JWK/JWK Sets, or base64-encoded input
- JWK Set key selection by the token's
kidheader (falls back to the first key when the token has none) - Supports both private and public keys (private keys are auto-converted for verification)
- Invalid signatures produce a nonzero exit status when
--key/JWTD_KEYis used - Opt-in claim validation with
--verify-claims(exp/nbf) plus--aud/--issassertions, exiting nonzero when a check fails — independent of the signature check - Nested token detection: JWT-inside-JWE and JWE-inside-JWE are decoded recursively
JWTD_KEYenvironment variable for default key configuration- Syntax-highlighted JSON output with a consistent color scheme
- Machine-readable output with
--jsonfor scripting and piping into tools likejq - Automatic conversion of
iat,exp, andnbftimestamps to human-readable RFC3339 dates, annotated with the time remaining or elapsed (expires in 14m,expired 2h ago,not yet valid, in 5m) - Accepts tokens as arguments, from stdin pipes, or via an interactive prompt
- Colors auto-disable when output is not a TTY, or are controlled explicitly with
--color - Shell completions (bash, zsh, fish) shipped in the Homebrew formula and the
.deb/.rpmpackages
curl -fsSL https://jwtd.sh/install.sh | shDownloads the release archive for the detected OS and architecture, verifies it against the release's checksums.txt, and installs the binary into ~/.local/bin — no root privileges and no package manager required. When Cosign is installed, the keyless signature over checksums.txt is verified as well; without it the checksum verification still runs and a mismatch aborts the installation.
Pass options after --:
curl -fsSL https://jwtd.sh/install.sh | sh -s -- --version v5.3.0 # pin a release
curl -fsSL https://jwtd.sh/install.sh | sh -s -- --dir /usr/local/binJWTD_VERSION and JWTD_INSTALL_DIR set the same two values. Run the script with --help for the full list. The script is install.sh in this repository; review it before piping it into a shell.
irm https://jwtd.sh/install.ps1 | iexThe same contract as the Unix installer: the release zip for the detected architecture is verified against the release's checksums.txt — and against the keyless Cosign signature when cosign is on PATH — before anything is written. The binary is installed into %LOCALAPPDATA%\Programs\jwtd, which is added to your user PATH; no administrator privileges are required.
Invoke-Expression cannot forward arguments, so the options are environment variables:
$env:JWTD_VERSION = 'v5.3.0' # pin a release
$env:JWTD_INSTALL_DIR = 'C:\tools' # install somewhere else
$env:JWTD_NO_MODIFY_PATH = '1' # leave PATH alone
irm https://jwtd.sh/install.ps1 | iexTo pass parameters directly instead, create the script block explicitly:
& ([scriptblock]::Create((irm https://jwtd.sh/install.ps1))) -Version v5.3.0 -NoModifyPathThe script is install.ps1 in this repository; review it before piping it into a shell.
brew install webcodr/tap/jwtdscoop bucket add webcodr https://github.com/webcodr/scoop-bucket
scoop install jwtdInstall with the built-in Windows Package Manager:
winget install WebCodr.jwtdThe manifest installs the same signed release binary as the other channels, packaged as a portable zip whose hashes are taken from the release's signed checksums.txt.
Install the prebuilt-binary package from the AUR with any AUR helper:
paru -S jwtd-bin
# or
yay -S jwtd-binThe package installs the same signed release binary used by the other channels; its hashes are taken from the release's signed checksums.txt.
Enable the COPR repository and install with dnf:
sudo dnf copr enable webcodr/jwtd
sudo dnf install jwtdThe COPR package repackages the same signed release binary used by the other channels, verified against the release's signed checksums.txt.
The repository is a flake. Run jwtd without installing it:
nix run github:webcodr/jwtd -- <token>Or install it into a profile:
nix profile install github:webcodr/jwtdFlake builds compile from source and report the commit they were built from; tagged release binaries carry the semantic version.
Requires Go 1.26+.
go install github.com/webcodr/jwtd@latestDownload a prebuilt binary from the Releases page. Binaries are available for:
- Linux (amd64, arm64)
- macOS (amd64, arm64)
- Windows (amd64, arm64)
Linux users can also install a .deb or .rpm package, which places the binary at /usr/bin/jwtd:
sudo dpkg -i jwtd-linux-amd64.deb # Debian, Ubuntu
sudo rpm -i jwtd-linux-amd64.rpm # Fedora, RHEL, openSUSEEach release also includes a checksums.txt with SHA-256 hashes for every archive and Linux package; verify a download with sha256sum --check checksums.txt.
checksums.txt is signed with Cosign keyless signing. To verify that the checksums really came from this project's release workflow, download checksums.txt.sigstore.json alongside it and run:
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/webcodr/jwtd/\.github/workflows/release\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txtEvery .tar.gz archive also ships a Syft SPDX SBOM named <archive>.sbom.json. Windows additionally ships a .zip archive (for WinGet) that wraps the same binary as the Windows .tar.gz.
Pass a token as an argument:
jwtd eyJhbGciOiJIUzI1NiIs...Pipe a token from stdin:
echo eyJhbGciOiJIUzI1NiIs... | jwtdOr run without arguments for an interactive prompt:
jwtd
Enter JWT/JWE: _JWE tokens (5 dot-separated parts) are automatically detected. Without a key, the protected header and encrypted part metadata are displayed:
jwtd eyJhbGciOiJSU0EtT0FF...Provide a decryption key with --key or -k:
jwtd --key /path/to/private-key.pem eyJhbGciOiJSU0EtT0FF...
jwtd -k /path/to/key.jwk eyJhbGciOiJSU0EtT0FF...Use the same --key flag to verify JWS signatures:
jwtd --key /path/to/public-key.pem eyJhbGciOiJSUzI1NiIs...An invalid signature prints Signature: INVALID and exits with a nonzero status. Claim validity, including expiry, is not part of this cryptographic signature check.
By default, claim validity never affects the exit code — expiry is shown only as a display annotation. Opt in to enforcement with --verify-claims, which validates the temporal claims (exp, nbf) and exits nonzero when the token is expired or not yet valid:
jwtd --verify-claims eyJhbGciOiJIUzI1NiIs...Add --aud and/or --iss to also require a specific audience or issuer; either flag implies claim validation, so the temporal checks run too:
jwtd --aud my-api --iss https://issuer.example eyJhbGciOiJIUzI1NiIs...The result is printed as a Claims: VALID / Claims: INVALID section (with the reason), and reported as claimsValid under --json. Claim validation is independent of the signature: it runs with or without --key, and when both are used the command exits nonzero if either check fails. A token with no exp is not treated as expired. The clock matches the displayed expired / not yet valid annotations, with no leeway. Claim validation applies to JWTs only; it is skipped (with a note) for JWEs.
The --key flag accepts:
- PEM files: RSA, EC, or Ed25519 keys (private or public), and X.509 certificates
- DER files: PKCS#1, PKCS#8, SEC 1, or PKIX encoded keys, and X.509 certificates
- JWK files: Single JSON Web Key or JWK Set (the entry matching the token's
kidis used; the first key when the token has nokid) - Base64 strings: Base64 or base64url encoded key material (PEM, DER, certificate, or JWK)
- Symmetric key files:
hmac:<file>uses the file's bytes as a symmetric key - Literal secrets:
raw:<secret>uses the text after the prefix as a symmetric key verbatim
Key detection first honors the raw: and hmac: prefixes, then tries an existing file path, then standard base64 followed by base64url. File contents and decoded inline data are parsed as JWK/JWK Set, then PEM, then DER keys or X.509 certificates. Base64-encoded key material is decoded the same way whether it arrives inline or in a text file. For signature verification, jwtd extracts the public key from X.509 certificates. For hmac: files, trailing newlines are trimmed only when the content is printable ASCII text (with tab, CR, and LF allowed); UTF-8/non-ASCII and other binary files remain byte-exact.
Symmetric secrets must be explicit. Key material that does not parse as PEM, DER, JWK, or an X.509 certificate is an error, not a symmetric key. jwtd used to fall back to using such bytes as an HMAC secret, which made any unsupported key format forgeable: a public key is a published value, so anyone who knew its bytes could sign an HS256 token that verified against it. Pass symmetric secrets as hmac:<file> or raw:<secret> and the failure direction stays closed regardless of what format turns up.
SSH public keys (id_*.pub, authorized_keys, and RFC 4716 armor) are detected and reported with a conversion hint rather than a generic error. Convert RSA and ECDSA keys with ssh-keygen -e -m PKCS8 -f <key>. Empty key material is rejected: the empty secret is known to everyone.
jwtd --key raw:my-hmac-secret eyJhbGciOiJIUzI1NiIs...
jwtd --key hmac:/path/to/secret.key eyJhbGciOiJIUzI1NiIs...Inline key material is visible to other local users through the process list and lands in shell history. Prefer a key file or JWTD_KEY for anything sensitive.
When a key argument is not an existing file, jwtd notes on stderr which reading it applied — literal secret or base64-decoded — so a value meant one way is never silently used another. Key files are the expected case and stay silent. The note goes to stderr, so piped stdout is unaffected.
Use --json to emit a single JSON object instead of the colored sections, for scripting or piping into tools like jq:
jwtd --json eyJhbGciOiJIUzI1NiIs...
jwtd --json --key key.pem eyJhbGciOiJSUzI1NiIs... | jq .signatureValidA JWT is emitted as { "header", "payload", "signature" }, plus "signatureValid" when a key is provided and "claimsValid" when claim validation is requested. Timestamps stay as their raw numeric claim values (no RFC3339 conversion) so consumers can do their own date math, and numbers are preserved exactly. A JWE is emitted as { "protectedHeader", ... } with either the encrypted part sizes (no key) or the decrypted payload (with a key). An invalid signature still prints the JSON and then exits nonzero.
Colors auto-disable when stdout is not a TTY. Override this with --color:
jwtd --color=always eyJhbGciOiJIUzI1NiIs... | less -R # force color through a pager
jwtd --color=never eyJhbGciOiJIUzI1NiIs... # disable color--color accepts auto (the default), always, or never. --json output is always plain, regardless of --color.
Set JWTD_KEY to provide a default key without using --key on every invocation:
export JWTD_KEY=/path/to/key.pem
jwtd eyJhbGciOiJSU0EtT0FF...The --key flag always takes precedence over JWTD_KEY.
jwtd prints sections with colored, indented JSON:
| Element | Color |
|---|---|
| Keys | Bold blue |
| Strings | Green |
| Numbers | Yellow |
| Booleans | Magenta |
| Null | Red |
| Labels | Bold cyan |
| Signature | Dim |
go build -o jwtd .A Nix development shell with Go and GoReleaser is available via the flake:
nix developgo test -v ./...Releases are cross-compiled and archived with GoReleaser, pinned in .mise.toml. Validate the configuration and produce a local snapshot build without publishing anything:
mise install
goreleaser check
goreleaser release --snapshot --clean --skip=signSnapshot artifacts are written to the git-ignored dist/ directory. --skip=sign is required locally because signing is keyless and needs a GitHub Actions OIDC identity; the release workflow exercises the signing path. Production releases remain a manually dispatched GitHub Actions workflow; GoReleaser only builds, packages, and signs — it never publishes GitHub releases or Homebrew metadata.