Great question! I've seen a wide range in the level of complexity of JWT tokens. Let's break your question down. (Wow this post got long. TL;DR at the bottom)
Intent of a JWT
I guess to start with, I'm not entirely sure what the underlying intent of a JWT claim is, which might help point me in the right direction.
To me, the intent of a JWT is to assert that a user has successfully logged in. Conceptually, when looking at a JWT system, I try to split out the following components and think about them separately:
- The "identity provider (IdP)": this is the component that handles the login page(s) and issues the JWT once the user has successfully completed whatever login stuff was required of them. We think of this as a kind of authority that is asserting that the user is who they claim to be.
- The Relying Party (RP) / Resource Server: this is the component (typically a filter on REST endpoints) that inspects the JWT and decides whether or not to serve the requested resource. This relies on the identity provider to have correctly verified the user's identity.
Validating the IdP's authenticity
In some cases these may really truly be separate components; for example if your app accepts Google or GitHub as an OpenID Connect (OIDC) Identity Provider. Or both "components" may be within your app, automagically provided by Spring Boot.
JWTs will always be cryptographically signed to prevent the user from tampering with them (for example extending the "exp" timestamp, or claiming to be a different user.
The case where your app is both the IdP and the RP, it's common to sign using the HMACSHA256 ("alg": "HS256") crypto algorithm. This is a "symmetric" signature meaning that the signer and the verifier need access to the same private key.
In the case where the IdP and RP are different, you typically see an "asymmetric" or "public key" crypto signature, typically RSA-SHA256 ("alg": "RS256"). This is more complicated and you end up needing to carry more metadata in the JWT to deal with establishing trust between the two components (see below).
Typical JWT claims
Case: self-signed (alg: HS256)
These can be very simple. I've seen some production systems as simple as:
Just a userID and an expiry timestamp. The REST controller can look up the user in the DB to check their access permissions.
If, for performance reasons, you want to avoid that db lookup, then you can put all needed info in the JWT. Typically this would be the user's group, role, or other access control permissions. If the info you're putting in the JWT starts to be sensitive (ie you don't want the end user seeing it) then you might need to move to encrypted JWTs (aka "JWE").
As your system gets more complex, you may find the need to track more metadata. For example if some parts of your app are ok to be accessed with password-auth but some pages need "step-up" with multi-factor, then you need to track in the JWT whether this user logged in with password or MFA. As you add more microservices, you may find that you need to add an "aud" (Audience) claim so that JWTs intended for one part of your app can't be used against a different microservice.
Case: separate IdP and RP (alg: RS256)
Here the JWT payload is going to be a bit more complex. The following things may come up in order to establish trust between the IdP and the RP:
- The RP will need to know the RSA public key to use when verifying the JWT signature. Typically this will be part of the IdP "metadata" that is available for download and needs to be added to your app's config data.
- "iss" (Issuer) Claim: The IdP will typically include its own name in the JWT so you can check that against your configured IdP metadata.
- "aud" (Audience) Claim: Typically the IdP will include the name of the RP in the Audience field so that you know this JWT was intended for your app and the user has not cut&pasted a JWT from another app that happens to use the same IdP. Obviously, the IdP needs to know what your app is expecting in the "aud" claim, so more metadata to exchange.
JTI (JWT ID)
JTI (JWT ID): this gives a unique ID to the JWT token. Some security scanners have started complaining if this is missing. Personally, unless I'm missing something, I don't think this is necessary unless you're trying to enforce that JWTs are one-time-use, or you want a hard logout mechanism where the server tracks "Revoked JTIs", etc.
Wow, this answer got a lot longer that I expected. Hopefully it's helpful!
TL;DR: At a bare minimum a secure JWT system needs:
- Crypto keys to sign and verify JWT signatures.
- A "sub" claim with a userID that your REST controllers can use to look up a user's permissions.
- A validity period to ensure that JWTs are short-lived. You can use either the "exp" (Expiration Time) or "iat" (Issued At) claims depending on whether you want the IdP or the RP to be in control of how long JWTs are good for.
Any other claims will depend on the complexity and needs of your app.