DEV Community

Murthy G
Murthy G

Posted on

My spec called the same token a JWS and a JWT. Both were right, and it took me a while to see why.(Every signed JWT is a JWS. Not every JWS is a JWT)

I'm working on a side project called AWL (Agent Work Layer). It's a small open protocol for handing work between AI agents: a manager agent writes a work order ("do this, with these tools, within this budget, by this deadline"), and a worker agent picks it up.

In v0.5 I added something called a Delegation Pass: a signed token that carries an order's permissions, so a gateway or another team's system can check what a worker may do without calling back to the server that issued the order.

When I wrote it up in the spec, I wrote this sentence:

A pass is a JWS in compact form (a JWT)...

Then I looked at it and wasn't sure it made sense. Is it a JWS or a JWT? I'd been using the two words as if they meant the same thing for years. So I went and actually read the RFCs, and this post is what I wish someone had shown me first.

The short version

  • JWS (JSON Web Signature, RFC 7515) is a way to sign something. That something can be any bytes.
  • JWT (JSON Web Token, RFC 7519) is a JSON object of claims (iss, sub, exp and so on) that is carried inside a JWS (signed) or a JWE (encrypted).

So a JWS is the envelope with a wax seal, and a JWT is a particular kind of letter you can put inside. Every signed JWT is a JWS. Not every JWS is a JWT.

My sentence was right after all. It just didn't explain itself.

A real one, taken apart

Here's an actual pass my code signed (wrapped for reading; on the wire it's one line):

eyJhbGciOiJFUzI1NiIsInR5cCI6ImF3bC1wYXNzK2p3dCIsImtpZCI6ImRlc2stMjAyNi0xMCJ9
.eyJpc3MiOiJodHRwczovL2Rlc2suZXhhbXBsZSIsInN1YiI6ImFnZW50OndvcmtlciIsImF3bF9vcmRlciI6Im9yZF80MiIsImF3bF90b29scyI6WyJmaWxpbmdzLyoiXSwiYXdsX2J1ZGdldCI6eyJ0b29sX2NhbGxzIjoyMH0sImF3bF9kZWFkbGluZSI6IjIwMjYtMTAtMThUMTg6MDA6MDBaIiwiYXdsX2F0dGVtcHQiOjEsImlhdCI6MTc5MjMyNDgwMCwiZXhwIjoxNzkyMzI1NzAwLCJqdGkiOiI3ZjNjOWExZSJ9
.1RNz1CnMmYz5J_IMfUff_GBX6LKOQH-8ntTqrWIQOFX_jgm3A5KyGFVjVL0CoIDn4SlPoTE73v4QO_0RGYSm3g
Enter fullscreen mode Exit fullscreen mode

Three parts, joined by dots. Each part is base64url (base64 with - and _ instead of + and /, and no = padding). You can decode it with nothing but Node:

node -e 'const [h, p, s] = process.argv[1].split(".");
  console.log(Buffer.from(h, "base64url").toString());
  console.log(Buffer.from(p, "base64url").toString());
  console.log(Buffer.from(s, "base64url").length, "signature bytes");' "$TOKEN"
Enter fullscreen mode Exit fullscreen mode
{"alg":"ES256","typ":"awl-pass+jwt","kid":"desk-2026-10"}
{"iss":"https://desk.example","sub":"agent:worker","awl_order":"ord_42","awl_tools":["filings/*"],"awl_budget":{"tool_calls":20},"awl_deadline":"2026-10-18T18:00:00Z","awl_attempt":1,"iat":1792324800,"exp":1792325700,"jti":"7f3c9a1e"}
64 signature bytes
Enter fullscreen mode Exit fullscreen mode

1. The header is the JWS part. It says how the thing is signed (alg), which key signed it (kid) and what it is (typ).

2. The payload is where the JWT comes in. The JWS spec doesn't care what's in there. Because mine is a JSON object of claims, this JWS is also a JWT. If I'd signed a webhook body or a PDF instead, it would still be a perfectly good JWS, but not a JWT.

3. The signature is computed over the first two parts exactly as they appear on the wire:

ES256(private key, base64url(header) + "." + base64url(payload))
Enter fullscreen mode Exit fullscreen mode

Notice what that means: anyone can read the payload. I just did, without a key. Signing proves who wrote it and that nobody changed it. It does not hide anything. If you need the contents secret, that's JWE (the encrypted sibling), and a JWT can live inside one of those too.

The family

All of these come from one IETF working group, JOSE:

Name RFC What it is
JWS 7515 Signed content (any bytes)
JWE 7516 Encrypted content (any bytes)
JWK 7517 A key written as JSON
JWA 7518 The list of algorithm names (ES256, EdDSA, ...)
JWT 7519 A claims set, carried in a JWS or a JWE

When people say "JWT" they nearly always mean "a JWS whose payload is claims", which is fine most of the time. It starts to matter when you're the one writing the verifier.

Why the difference mattered for me

Most of the well-known JWT bugs are really mistakes in how the JWS layer gets trusted. Splitting the two ideas in my head made it easier to avoid them.

Read the header, but don't believe it

The header is chosen by whoever made the token, and it's checked before you know whether the signature is any good. The classic bugs:

  • "alg": "none": an unsigned token, which some libraries used to accept.
  • Algorithm confusion: the token says HS256, and the verifier uses your RSA public key as the HMAC secret. Since the public key is public, anyone can forge tokens.

So the verifier never asks the token which algorithm to use. It has a fixed allow-list:

if (header.typ !== "awl-pass+jwt") throw new PassError("MALFORMED", `typ must be "awl-pass+jwt"`);
if (!Object.hasOwn(ALGS, header.alg)) throw new PassError("UNSUPPORTED", `unsupported alg "${header.alg}"`);
Enter fullscreen mode Exit fullscreen mode

ALGS holds exactly two entries: ES256 (P-256) and EdDSA (Ed25519). Then it only tries keys whose type matches that algorithm, so an RSA or HMAC key can never be used here.

Give your token its own typ

This one I didn't know about until I read RFC 8725 (JWT best practices). If a system issues two kinds of JWT, say login tokens and passes, signed by related keys, an attacker might present one where the other is expected. Both have a valid signature, after all.

A unique typ like awl-pass+jwt closes that off. A login token sent where a pass is expected fails at the very first check, before the signature is even looked at.

Check things in the right order

This is the order my verifier runs in, and the order I'd suggest for any JWT:

  1. typ and alg on the allow-list
  2. The signature, using the issuer's public key
  3. exp and iat, allowing a little clock skew (I allow 60 seconds on iat, because the issuer's clock can run slightly ahead of the verifier's)
  4. iss, sub, aud: is this from who I expect, for who I expect?
  5. Only then, read the claims and make a decision

Everything before step 2 is input from an attacker. Everything after it is input from the issuer.

exp is your revocation

There's no "un-sign". Once a JWS is out, it verifies until it expires. For passes I kept it simple: they last 15 minutes and never past the order's deadline, and the worker renews them while it's working. When an order gets cancelled, the server just stops renewing. The worst case is a pass that stays valid for up to 15 more minutes, and I'm OK with that tradeoff.

A small gotcha: ES256 signature bytes

I wrote the signing and verifying with WebCrypto only, no JWT library, so the same code runs in Node, browsers, Deno and Bun. Signing ends up being a few lines:

const header = { alg: "ES256", typ: "awl-pass+jwt", kid };
const input = `${b64url(json(header))}.${b64url(json(claims))}`;
const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, utf8.encode(input));
return `${input}.${b64url(new Uint8Array(sig))}`;
Enter fullscreen mode Exit fullscreen mode

The gotcha: JWS wants an ES256 signature as 64 raw bytes (r followed by s). WebCrypto gives you exactly that. Node's own crypto.sign(), though, gives you DER-encoded signatures by default (about 70 bytes) unless you pass dsaEncoding: "ieee-p1363". If you mix the two, every signature fails to verify, and the error won't tell you why. That's the "64 signature bytes" in the output above.

So which word should you use?

You're signing... It's a...
A login or access token with sub, exp, aud JWT (in a JWS)
A permission grant with claims, like my pass JWT (in a JWS), with its own typ
A webhook body, a file, a release manifest Plain JWS
Claims that must stay secret JWT in a JWE

What I'd tell myself

  • Use a library for most projects. jose is excellent. I hand-rolled mine because I wanted zero dependencies in a spec's reference implementation, and the code ended up around 300 lines plus tests. I wouldn't do it for an app.
  • Signed is not encrypted. Assume everyone can read your payload.
  • Never let the token pick the algorithm.
  • Give each kind of token its own typ.
  • Keep tokens short-lived, because that's the only revocation you get for free.

If you want to see the whole thing, the pass code is in packages/core/src/pass.ts and the rules are in SPEC.md ยง10.2. It's a personal, experimental project, so if you spot something wrong in how I verify these, I'd really like to hear about it. Issues are open on GitHub.

Top comments (0)