[I]f I want the JWS token content to be non-visible, I keep hearing people mention JWE. But is there a security reason why I shouldn't be encrypting them myself?
On the contrary, there is a security reason why you should avoid JWT altogether: software implementing it tends to be riddled with vulnerabilities which are encouraged by a poor design, where the adversary tells you which algorithm to use in the alg
field and the software happily obliges.
The standard examples of exploits are specifying the none
algorithm to disable verification altogether, or specifying a secret-key authenticator like HMAC-SHA256 when you meant to use a public-key signature like RSA—the adversary knows the public key, and can therefore compute HMAC-SHA256 under that public key that will pass HMAC-SHA256 verification.
Instead, you are much better off just using a single authenticated cipher on a binary blob of data.
If I take the JWT, encrypt it with AES-256-CBC (I prefer CBC over GCM because I don't want to fall into equal IV attacks), then MAC it with some hashing function like SHA-512 (HMAC-SHA512), convert both the ciphertext and the MAC to base-64, and put them in a printable string. Am I vulnerable to some security attacks if I use the outcome to authenticate users on a web application? Or is just "not nice"?
This just doesn't follow the dogma of JavaScript and JWT cultists. I would say it is not nice for JWT to inflict misbegotten cryptographic disasters on the world in the name of…overengineering or something.
That said, while AES-CBC/HMAC-SHA256 in encrypt-then-MAC composition using randomly chosen IVs is generally OK, you should be careful composing primitives this way, especially ones with sharp edges like AES and CBC:
Do not touch anything before you have verified the authenticator (in constant time!). It may be tempting to have one function to decrypt the message, and another function to verify the authenticator, and hope that downstream users will do both—but this is a disaster waiting to happen. Make one function that either verifies and decrypts, or fails altogether without divulging any plaintext. Unauthenticated data is pure evil—don't touch it!
Pay close attention to security contracts; note that CBC and GCM are different types of thing. You are evidently aware of the importance of authentication, which is good—I am emphasizing this just because your parenthesis seemed to compare CBC and GCM directly, which is apples-to-oranges because they have totally different security goals; while encrypt-then-MAC with AES-CBC and HMAC-SHA256 is a more similar type of thing to AES-GCM in that they have the same security goals and differ only in usage requirements (and size of authenticator).
If you're having a sequential conversation, AES-GCM is may be a better option than AES-CBC/HMAC-SHA256 if it is simpler to use: you can safely use the number of messages you have sent so far as the nonce, and have the receiver require that number to be the nonce. This enables the receiver to quickly drop forgeries on the floor, and which ensures that a large class of insecure implementations will immediately fail to interoperate rather than silently leak secrets.
(When AES-GCM was introduced to TLS in RFC 5288, they made the mistake of allowing arbitrary sender-chosen nonces; TLS 1.3 in RFC 8446 fixed this mistake so that broken implementations will fail fast and noisily.)
If counting is hard because of, say, VM state rollbacks, random IV generation might be hard too for the same reason. If this is relevant, you may want to use a SIV-type scheme, synthetic IV, like AES-SIV, which chooses the IV as a pseudorandom function of (an optional nonce and) the message; at worst, if you repeat a nonce, the adversary can tell whether the whole message was repeated or not.
Make sure to generate known-answer test vectors for the whole AES-CBC/HMAC-SHA256 composition that you can automatically do self-tests with.
AES invites timing attacks unless you can guarantee all the way down your software stack that you're using hardware support for it like AES-NI. It can be computed in constant time software, but it's very slow to do so. Alternatives like Salsa20 in the the authenticated cipher NaCl crypto_secretbox_xsalsa20poly1305 do not have this flaw. Of course, I don't know how easy it is to use anything other than AES in your software stack; if the tradeoff may be between using AES here and throwing up your hands and giving up on security, maybe AES is OK.
Side note:
I understand that signatures make it impossible for the tokens to be modified.
There are two very different things that JWS conflates under the term ‘signature’, which makes for exceptionally bad API design.
Signatures enable anyone who knows a public key to verify a message, but only the owner of the private key can sign messages.
Signatures are verifiable by a third party, and so are useful as an adversarial audit trail—if you claim to an arbitrator that a vendor stole your money, but they can present the check to the arbitrator, the arbitrator can verify it to determine that you're lying.
Signatures are relatively expensive to compute, costing hundreds of thousands of CPU cycles per message.
Authenticators or message authentication codes (MACs) allow anyone who knows a secret key to verify or authenticate messages.
Authenticators are not verifiable by third parties—if Bob shows Charlie a message he claims Alice sent him, the putative authenticator from Alice doesn't mean anything because Bob could have forged it too.
Authenticators like Poly1305 can be some of the cheapest cryptographic primitives ever, costing under one cycle per byte. (HMAC-SHA256 is nowhere near the fastest authenticator but it is still much faster than public-key signature.)