← All posts
    jwt · jwe · security

    JWT vs JWE: when signed is not enough

    Rodrigo Vidal
    Rodrigo VidalMay 5, 2026 · 6 min read

    Every backend engineer has shipped a JWT. Far fewer have shipped a JWE. Both come from the same family of specs — JOSE (JSON Object Signing and Encryption) — but they answer different questions, and the default JWT habit is silently wrong for some surfaces.

    This is a short note on the difference, the trap people fall into, and what Authaz issues by default.

    A signed JWT is not encrypted

    The thing labeled "JWT" in 90% of tutorials is more precisely a JWS (JSON Web Signature) — a base64-encoded payload with a signature appended:

    signed JWT (JWS · RS256)
    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
      .eyJzdWIiOiJ1c3JfMDFIWlg3IiwiZW1haWwiOiJ2YWxAYWNtZS5jb20i...
      .GdLk2…SignatureBytes…
    {
      "sub":   "usr_01HZX7…",
      "email": "val@acme.com",
      "roles": ["admin", "billing"],
      "exp":   1773292800
    }
    encrypted JWT (JWE · RSA-OAEP-256 + A256GCM)
    eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIn0
      .OKOawDo13gRp2ojaHV7LFpZcgV7T6DV…   encrypted CEK
      .48V1_ALb6US04U3b…                  IV
      .5eym8TW_c8SuK0lt…                  opaque ciphertext
      .XFBoMYUZodetZdvTiFvSkQ             auth tag
    [ unreadable until decrypted with the recipient private key ]

    A signed JWT is integrity-protected — anyone can read the claims, but no one can forge them without the signing key. An encrypted JWT (JWE) is integrity- AND confidentiality-protected — no one can read the claims without the recipient's private key.

    The trap: a lot of people treat the base64 payload as if it were secret. It isn't. Anyone with browser devtools can paste a JWT into jwt.io and read every claim. If you put PII, internal user IDs, or feature flags in there, you've published them.

    When signed is enough

    For most session and API auth, signed is correct:

    • The token is short-lived.
    • The claims are things the user already knows about themselves (their own ID, their own roles).
    • The recipient is a trusted backend that just needs to verify the token, not protect the contents.

    Signed JWTs are cheap to verify, cacheable via JWKS, and survive every CDN, proxy, and observability tool you'll route them through. This is the right default.

    When JWE earns its weight

    You want JWE when the claims themselves are sensitive AND the token transits surfaces you don't trust. A few real cases:

    • Step-up tokens carrying a one-time medical record ID that should never appear in a CDN access log.
    • Refresh tokens persisted in mobile-app storage that you don't want a curious user to dump and inspect.
    • Federation handoffs to third parties where you ship attributes (compliance flags, internal tier names) that the third party shouldn't see if they tee logs.
    • B2B partner tokens carrying tenant secrets — keys, customer-supplied connection strings — that shouldn't survive a mistakenly-shared HAR file.

    The strongest pattern when claims are both sensitive and need to be authenticated is nested JWS-in-JWE: sign the claims first (so any party with the verification key can detect tampering), then encrypt the result (so only the key holder can read the contents). Both properties hold; neither leaks the other's failure mode.

    What Authaz issues

    By default, Authaz issues signed JWTs (RS256) for access tokens and session tokens. Claims are intentionally minimal: sub, aud, iss, exp, org, roles, amr. Nothing in there is information the user shouldn't see about themselves.

    Where it matters, we switch:

    • Refresh tokens are opaque — server-side state. We don't ship structured claims in long-lived tokens.
    • Step-up assertions for sensitive actions can be issued as JWE with the resource server's public key. The claims stay sealed across CDNs, audit pipelines, and the client itself.
    • Cross-tenant federation tokens for buyer-provided IdPs can be issued as nested JWS-in-JWE so the partner can verify, decrypt, and consume — but no observer in between can inspect.

    The choice is configurable per resource, not per tenant. Most apps need one signed-JWT verifier and never touch JWE; the surfaces that need it can opt in without affecting everything else.

    Pitfalls worth memorizing

    • alg: none is not a feature. Reject it at the verifier. Several libraries used to honor it.
    • Always pin alg. Don't let the token tell you which algorithm to use; the verifier already knows.
    • Don't trust the kid. Use it to select a key from your JWKS — never to fetch a remote key on demand.
    • Rotate keys. A signing key that lives forever is a signing key the next breach takes with it.
    • JWE doesn't replace HTTPS. It seals the payload, but the token still belongs in Authorization: Bearer, not a query string.

    TL;DR

    Signed JWTs answer "who is this and can I trust the claims?" — perfect for ~95% of auth.

    JWEs answer "who is this, can I trust the claims, AND can I keep the contents private from everyone in between?" — and you only need that for the surfaces that genuinely warrant it.

    Knowing which question your token is answering is the difference between auth that holds up under a security review and auth that quietly leaks claims for years. If you want to talk to us about either pattern, we're around.