# JWS — signed tokens

A **JSON Web Signature** ([RFC 7515](https://www.rfc-editor.org/rfc/rfc7515)) is a JWT whose payload is signed: the data travels in the clear (base64url-encoded), but anyone who tampers with it invalidates the signature.

Import:

```ts
import { sign, verify } from "unjwt/jws";
// or from the flat barrel:
import { sign, verify } from "unjwt";
```

## Basic usage

```ts [sign-verify.ts]
import { sign, verify } from "unjwt/jws";
import { generateJWK } from "unjwt/jwk";

const key = await generateJWK("HS256");

const token = await sign({ sub: "user_1" }, key, { expiresIn: "1h" });
const { payload } = await verify(token, key);

console.log(payload);
// { sub: "user_1", iat: 1736860800, exp: 1736864400 }
```

The algorithm (`HS256`) was read from `key.alg`. The `iat` and `exp` claims were added because you passed `expiresIn`. Claim validation ran automatically on `verify` because the payload is a JSON object.

## Signed-in-the-clear, not encrypted

A JWS is **not private**. Decoding the second segment of the token with any base64url decoder reveals the payload:

```ts
import { base64UrlDecode } from "unjwt/utils";

const [, payloadSegment] = token.split(".");
console.log(base64UrlDecode(payloadSegment));
// {"sub":"user_1","iat":1736860800,"exp":1736864400}
```

What a JWS gives you is **integrity** and **authenticity**: flipping a bit in the payload changes its base64url string, which invalidates the signature, which makes `verify()` throw. If you need **confidentiality** instead (or as well), use [JWE](/jwt/jwe).

## Symmetric vs. asymmetric

Two families of signing keys, chosen by the algorithm:

- **Symmetric** (`HS256`, `HS384`, `HS512`) — one secret key signs **and** verifies. Use when signer and verifier are the same party (e.g. your API signs tokens and your API verifies them).
- **Asymmetric** (`RS*`, `PS*`, `ES*`, `Ed25519`/`EdDSA`) — a private key signs, a public key verifies. Use when anyone should be able to verify without holding signing power (e.g. you publish a JWKS endpoint; third parties verify).

Asymmetric round trip:

```ts
const { privateKey, publicKey } = await generateJWK("ES256");

const token = await sign({ sub: "user_1" }, privateKey, { expiresIn: "1h" });
const { payload } = await verify(token, publicKey);
```

Full algorithm table: [JWS algorithms →](/jwt/jws/algorithms).

## Standard claims, automatic

When the payload is an object, `verify()` runs claim validation automatically (no opt-in needed):

- `exp` — rejected if expired (past current time minus `clockTolerance`).
- `nbf` — rejected if used before its "not before" timestamp.
- `iat` — must be a valid number if present.
- `aud`, `iss`, `sub`, `typ`, `maxTokenAge`, `requiredClaims` — checked when you pass the matching option.

```ts
const { payload } = await verify(token, key, {
  issuer: "https://auth.example.com",
  audience: "my-api",
  maxTokenAge: "24h",
});
```

See [`validateJwtClaims`](/utilities#validatejwtclaims) for the full option list.

<note>

Claim validation runs **independently of the typ header**, because `typ` is attacker-controlled. A token whose payload happens to look like JWT claims gets validated even if `typ` is absent or exotic. Pass `validateClaims: false` only when you've genuinely chosen JWS-for-arbitrary-bytes semantics.

</note>

## Going further

- [Signing in depth →](/jwt/jws/signing) — every option on `sign()`, including `b64: false` (unencoded payload) and `kid` fallback.
- [Verifying in depth →](/jwt/jws/verifying) — JWKSet retry, dynamic key lookup, allowlist inference, advanced claim options.
- [Multi-signature →](/jwt/jws/multi-signature) — more than one signer on a single payload (JSON Serialization).
- [Algorithms →](/jwt/jws/algorithms) — picking between HMAC, RSA-PSS, ECDSA, EdDSA.
