JWT Doctoran ARLing tool

"jwt malformed", "invalid signature", "jwt expired"? Decode it and see exactly why.

Paste a JWT and, optionally, the issuer/audience/algorithm you expect and the key to verify it against. Get the header and payload decoded, every RFC 7519/7515 rule checked, and the signature verified with your browser's own Web Crypto: HS256/384/512, RS256/384/512, PS256/384/512, ES256/384/512.

01

Three reasons a JWT that looks fine still fails to verify.

RFC 7519 and RFC 7515 define exactly what a valid, current, correctly-signed token looks like. Almost every real-world failure is one of these three.

  1. Not three parts.

    A JWT is header.payload.signature: exactly two dots, each part valid base64url JSON. A "Bearer " prefix, wrapping quotes, or a stray newline from copy-paste all produce "jwt malformed" before verification even starts.

  2. Wrong key, wrong algorithm.

    alg: "none" means unsigned. A secret pasted where a public key belongs (or the reverse) can never verify. The token's own header.alg should never be trusted to pick the algorithm: that's how algorithm-confusion attacks work.

  3. Clock, not code.

    exp, nbf and iat are compared against the verifying server's own clock. A token that looks expired seconds after login is usually a clock-skew problem between two machines, not a short expiry.

02

Paste the token. Get the decoded claims and the exact problem.

Decoding and signature verification both run in your browser: the token, the secret, and any key you paste never leave this page.

fn await JwtDoctor.diagnose(config)
client-side, no network
Token
A "Bearer " prefix, wrapping quotes, or stray whitespace are detected and cleaned automatically.
Expected values (optional)
Verification key (optional)
A private key or a certificate here is a sign you have the wrong file: verification always uses a public key or a shared secret.
Result · idle
Fill in the form and press Diagnose or ⌘↵ to see the result.
status
idle
not run yet

03

One async function. Token in, decoded diagnosis out.

No server, no API key, no signup. doctor-jwt.js is plain JavaScript: read it, fork it, or run it in your own scripts or CI.

doctor-jwt.js 0 dependencies
import { diagnose } from './doctor-jwt.js';
// or, loaded globally: const { diagnose } = window.JwtDoctor;

const result = await diagnose({
  token:    rawJwtString,
  expected: { issuer, audience, algorithm, clockSkewSeconds },
  key:      { type: 'none' | 'hmac-secret' | 'rsa-public-pem' | 'jwk', value },
});

// result
{
  "status":          "fail" | "warn" | "pass",
  "summary":         "…",
  "decoded":         { header, payload, signaturePresent },
  "signatureStatus": "valid" | "invalid" | "not_checked" | "unsigned" | "…",
  "expected":        { issuer, audience, algorithm, clockSkewSeconds },
  "problems":        [ { severity, code, message, path, value, fix } ],
  "fixes":           [ { title, value, where } ],
  "checklist":       [ "…" ],
  "disclaimer":      "…"
}

Everything runs client-side. The form above calls this exact function in your browser. There is no backend, no API key, and no request that carries your token, secret, or key anywhere.

It decodes the header and payload, checks structure against RFC 7515 and every timing/issuer/audience claim against RFC 7519, and, if you supply a key, verifies the signature through crypto.subtle for HS256/384/512, RS256/384/512, PS256/384/512 and ES256/384/512.

Free and open source. Found a case it gets wrong? Open a GitHub issue on the repo. No ads, and no tracking beyond anonymous usage counts.

04

Free.

No account, no payment, no usage limit: it runs as a static page in your browser, so there is no server to bill for.

05

Questions developers actually search for.

Straight answers to the same JWT questions this tool diagnoses, for when you just need the answer, not the checker.

What does "jwt malformed" mean?

The string you handed to jwt.verify() (or jose's jwtVerify()) is not a valid JWS Compact Serialization: RFC 7515 defines a JWT as exactly three base64url segments joined by two period characters, header.payload.signature. "jwt malformed" fires when that shape is broken: a copy-paste dropped a character, a "Bearer " prefix or surrounding quotes got included, the value was line-wrapped, or one of the segments is not valid base64url or doesn't decode to JSON. It is a structural error, checked before signature or claims.

Why do I get "invalid signature" although the secret is right?

The signature check is byte-exact over the header and payload segments as they were originally encoded, so the usual causes are: the secret or key you're verifying with does not match the one that actually signed it (a rotated Supabase JWT secret, an old copy in an environment variable, the wrong project's key); the token was re-encoded after signing (even reformatting whitespace inside the JSON before re-encoding changes the signed bytes); or a secret got used where a public key was needed, or the reverse. Confirm the current key first, byte for byte, before assuming the token itself is broken.

Why is my token expired right after login?

Usually a clock mismatch, not a short-lived token. RFC 7519 defines "exp" as a NumericDate compared against the verifying server's own clock, and explicitly allows "some small leeway" for clock skew; if the machine verifying the token is more than a few minutes ahead of the one that issued it (common on a VM, container, or a laptop that just woke up), a token minted seconds ago already looks expired. Set the clock skew tolerance above to a few minutes to test that theory, and check NTP sync on whichever server verifies tokens.

What is the difference between a secret and a public key for JWT verification?

An HMAC algorithm (HS256/HS384/HS512) uses one shared secret for both signing and verifying: whoever holds it can do either. An asymmetric algorithm (RS256/384/512, PS256/384/512, ES256/384/512) uses a private key to sign and a separate public key to verify; the public key can verify signatures but cannot create new valid ones. Pasting a public key where an HMAC secret is expected, or a shared secret where a public key is expected, cannot work, and is also the shape of a known "algorithm confusion" attack: a verifier must pin which algorithm and which kind of key it accepts, never trust the token's own header.alg to decide.

Is it safe to paste my token here?

Nothing you paste is sent anywhere: decoding is plain base64url + JSON.parse, and signature verification runs through your browser's own Web Crypto API (window.crypto.subtle), entirely client-side. There is no backend for this tool and no request that carries the token or key. As general hygiene, still avoid pasting a production secret into any tool including this one, and prefer rotating a secret you're unsure about over trusting it stayed private.

How do I verify a Supabase or Firebase JWT?

Supabase access tokens are HS256, signed with your project's JWT secret from Project Settings → API (or verified via its published JWKS on newer projects using asymmetric keys): paste that as an HMAC secret above. A Supabase anon or service_role key is itself a JWT but is a static project API key, not a user session; it decodes fine but verifying it just confirms the JWT secret, not a login. Firebase ID tokens are RS256, signed by Google and issued with iss "https://securetoken.google.com/<project-id>": verify them against Google's public keys at googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com, selecting the key whose "kid" matches the token's header. A Firebase custom token (minted server-side, has a "uid" claim) is different again: it must be exchanged client-side via signInWithCustomToken() before you get a verifiable ID token.