Post

JOSE 101 [2/2]: A Practical Guide to JWT, JWA, JWS and JWE in .NET

JOSE 101 [2/2]: A Practical Guide to JWT, JWA, JWS and JWE in .NET


In the previous post, I covered JWS with some tokens and implementation samples. Now I’ll walk you through JWE in the same way.

JWE - Encryption algorithms: dir vs RSA-OAEP


Two choices, not one

In part 1, signing asked one question: which algorithm produces the signature? Now with JWE, encrypting asks two, and they’re answered by two different headers.

  • The first one is key management, and it goes in alg. It decides how the recipient ends up with the key that actually encrypts your payload, called the Content Encryption Key (CEK). 💡 You can hand that key over ahead of time and use it directly (dir), or generate a fresh random key per token and ship it inside the token, wrapped with the recipient’s public key (RSA-OAEP).

  • The second one is content encryption, and it goes in enc. It’s the algorithm that actually scrambles the payload with that key (A256GCM).

Side by side, that’s the whole difference from a signed token’s header: a JWS header carries one algorithm, a JWE header carries two:

1
{ "alg": "<dir | RSA-OAEP>", "enc": "A256GCM" }

💡 Notice there’s no typ here. It’s an optional field, and jose-jwt fills it in when signing but not when encrypting, so the JWE tokens we’ll see below carry just those two.

They’re picked independently, so the same enc can pair with any alg.

Both examples below use A256GCM for content and differ only in how the key gets there, which keeps the comparison readable.

Lastly, encrypting also means the payload is worth hiding, so these examples carry different claims than the signed ones we saw earlier, to illustrate that:

The five segments

A JWE has five parts: header.encryptedKey.iv.ciphertext.tag.

  • The header declares alg and enc.
  • The encryptedKey carries the CEK, wrapped.
  • The iv (initialization vector) is the random value that makes the same payload encrypt to different ciphertext every time.
  • The ciphertext is your payload, scrambled.
  • The tag is the authentication tag, which proves on decryption that nobody tampered with the ciphertext.

The tag matters more than it looks: A256GCM is an authenticated encryption mode, so a JWE doesn’t only hide the content, it also detects modification. You can prove it by flipping a single bit anywhere and decryption fails.

Like in the previous post, let’s play with a variety of tokens using the app.

If you haven’t yet, clone JOSE_101, run it and choose this menu option: Verify / Decrypt (validate a token). This time, we’ll use the JWE options.

Then paste the token and the secret key to verify it. You can repeat the same process for all tokens below, while choosing the right menu option for each.

dir (direct) + A256GCM - Symmetric

With dir there’s nothing to wrap. The 256-bit key you already share with the recipient is the one used to encrypt the content, so no separate CEK is generated and nothing needs to be carried in the token. Same trust model as HS256: simple, fast, and only usable between parties that already trust each other with the same secret, like we saw earlier.

When to use it: whenever both ends are already inside the same trust boundary. Think two services inside your own infrastructure reading the same key from the same secret store, or an app encrypting its own session state before handing it to the browser and decrypting it on the way back.

Trade-off: all of that assumes the key reached both ends safely, and JOSE says nothing about how it got there. Every party that can decrypt can also encrypt, so a leaked key doesn’t only expose past tokens, it lets an attacker forge new ones your own services will happily accept.

Token example:

💡 Notice the header segment: it ends in ...BMjU2R0NNIn0 and is followed by .., two dots with nothing between them. That empty second segment is dir made visible. There’s no key to transport, so the slot stays there (the five segments are always five) but carries nothing.

Shared secret key:

Implementation:

RSA-OAEP + A256GCM - Asymmetric

With RSA-OAEP (Optimal Asymmetric Encryption Padding), a fresh random 256-bit CEK is generated for this single token and used to encrypt the payload. That CEK is then encrypted with the recipient’s public key and placed in the second segment, so only the matching private key can unwrap it.

When to use it: whenever the sender shouldn’t need any secret of yours. Think a partner system that needs to send you data only you should read, using a public key you published, or an identity provider issuing tokens whose claims are sensitive enough that only the intended recipient should see them.

Trade-off: you pay for that reach twice: an RSA operation on each end, and a noticeably bigger token, since the wrapped key now has to travel inside it. And if the private key ever leaks, every token ever sent to that recipient becomes readable, since RSA-OAEP has no forward secrecy.

That’s the mirror image of signing we’ve seen before, but while in JWS, the private key signs and the public key verifies, in JWE, the public key encrypts and the private key decrypts, so anyone can send you a token only you can read.

Token example:

💡 Compared to the dir token, everything grew in the second segment, because it’s now carrying the wrapped CEK.

RSA Private key:

Implementation:

Same claims, two encryptions

Both tokens above encrypt the exact same payload with the exact same enc. Only alg changed, so here’s where every one of those 349 extra characters went:

SegmentdirRSA-OAEP
header39 chars46 chars
encryptedKeyempty342 chars
iv16 chars16 chars
ciphertext43 chars43 chars
tag22 chars22 chars
Whole token124 chars473 chars

The iv, ciphertext and tag rows are identical, because those three are produced by enc, and enc didn’t change. The only row that grew is encryptedKey, which is exactly the one alg is responsible for.

That’s the alg vs enc split from the JWA section, showing up in bytes.

And those 342 characters should look familiar: it’s the same size as the RS256 signature we’ve seen earlier. Same cause, an RSA 2048 operation. Whether RSA is signing a hash or wrapping a key, what it produces is the same size, and that’s the price you pay for not having to share a secret beforehand.

⚠️ Notice that the ciphertext is 43 characters in both, which decodes to 32 bytes, exactly the length of the JSON it hides. Authenticated encryption protects the content, not its length. If your claims vary in size in a meaningful way, a passive observer still learns something from the token’s size alone.

💡 Rule of thumb:

  • Use dir when both ends already share a secret, the same situation where you’d reach for HS256.
  • Use RSA-OAEP when the sender only knows your public key.

And remember, encrypting is not signing: a JWE proves the content wasn’t modified, not who sent it. When you need both, you nest them, and that’s exactly what comes next.


Nested JOSE: sign then encrypt


Now, you’ve reached the final and most advanced topic, and it builds on every concept we’ve seen through the series.

Nesting is not another algorithm, it’s a technique. JWS proves who sent a token but lets anyone read the payload; JWE hides the payload but doesn’t prove who sent it. Sometimes you need both guarantees at once, and that’s what nesting solves.

When to use it: whenever you need both guarantees together. Think an identity provider issuing tokens whose claims are sensitive AND whose issuer must be verifiable, or an inter-service message that must be private in transit yet provably from the right sender.

Trade-off: you pay both costs. Two sets of keys to manage (signing keypair + decryption key), two operations on each end, and a bigger token. You also have to pick the right order: sign then encrypt (as here) hides the signer’s identity from anyone but the recipient. The reverse (encrypt then sign) reveals who signed but not what.

How it works: nest them with the following steps:

  • Sign the payload first to produce a JWS
  • Encrypt that entire JWS as the payload of a JWE.

The result is a regular JWE on the outside, so the recipient decrypts it first and finds a JWS inside. Verifying that inner signature completes the chain: confidentiality from the outer layer, authenticity from the inner one.

💡 One detail makes this work by convention: the outer JWE header carries "cty": "JWT", telling the recipient that the decrypted content is itself a JWT (the inner JWS), and should be parsed and verified, not treated as a final payload.

1
{ "alg": "dir", "enc": "A256GCM", "cty": "JWT" }

Token example:

This sample was created with RS256 for signing (inner JWS) and dir + A256GCM for encryption (outer JWE).

From the outside it looks like any dir JWE, with the same .. empty encrypted-key slot. The difference is in the header: that extra cty field, and a much larger ciphertext segment, because the payload being encrypted is now a full JWS (header, payload, and RS256 signature included).

To unwrap it using the app, choose the menu options in the following order:

Decryption key (outer JWE, dir):

Verification key (inner JWS, RS256):

Implementation:

💡 The combination above is just one example. The app lets you mix any signing algorithm (HS256, RS256, ES256) with any encryption algorithm (dir, RSA-OAEP), so you can explore all six combinations and see how each one affects the token size and the keys involved.

If you followed the steps correctly, you should see this workflow:


Final thoughts


We finally made it. I’ve scratched the surface of JWT, JWA, JWS, and JWE.

Don’t forget, the algorithms and combinations shown across both parts are a didactic subset.

I can’t finish without naming the main JOSE algorithms and formats worth exploring:

  • PS256 (RSA-PSS)
  • EdDSA / Ed25519
  • ECDH-ES
  • Key-wrap algorithms like A128KW / A256KW, other AES-GCM variants, alternative curves (P-384, secp256k1), and JWK / JWKS key formats.

Thank you for making it this far, and happy coding!



Check the project on GitHub



This post is licensed under CC BY 4.0 by the author.