JWT¶
The JWT module signs and verifies JSON Web Tokens (RFC 7519) in the JWS (signature) profile, and reads the JSON Web Key Sets that identity providers publish. Encrypted tokens (JWE) are out of scope.
The module reuses the OpenSSL crypto already linked for TLS and the Glaze JSON parser, so it adds no dependency. The AERONET_ENABLE_JWT CMake option defaults to ON whenever both AERONET_ENABLE_OPENSSL and AERONET_ENABLE_GLAZE are enabled; set it to OFF to leave the module out.
| Header | Content |
|---|---|
<aeronet/jwt.hpp> |
Jwt::encode(), Jwt::tryDecode(), JwtVerifyOptions, DecodedJwt |
<aeronet/jwt-key.hpp> |
JwtKey |
<aeronet/jwks.hpp> |
Jwks |
<aeronet/jwt-algorithm.hpp>, <aeronet/jwt-error.hpp> |
JwtAlgorithm, JwtError |
Sign and verify¶
#include <aeronet/jwt.hpp>
#include <string>
#include <string_view>
const JwtKey key = JwtKey::Hmac("a-long-random-secret-from-a-secret-store");
const std::string token = Jwt::encode(R"({"sub":"alice","exp":4102444800})", key, JwtAlgorithm::HS256);
JwtVerifyOptions options;
options.allowedAlgorithms = JwtAlgorithmSet{JwtAlgorithm::HS256};
JwtError err = JwtError::None;
if (const DecodedJwt decoded = Jwt::tryDecode(token, key, options, err)) {
const std::string_view subject = decoded.subject(); // "alice"
} else {
const std::string_view reason = ToString(err);
}
Jwt::encode() takes the claims as a JSON object and returns the compact serialization header.payload.signature. An optional fourth argument sets the kid header. Jwt::tryDecode() verifies the signature, then the claims, and returns the decoded token.
Keys and algorithms¶
| Family | Algorithms | Key |
|---|---|---|
| HMAC | HS256, HS384, HS512 |
JwtKey::Hmac(secret) |
| RSA PKCS#1 v1.5 | RS256, RS384, RS512 |
JwtKey::FromPem(pem) with an RSA key |
| RSA-PSS | PS256, PS384, PS512 |
JwtKey::FromPem(pem) with an RSA key |
| ECDSA | ES256, ES384, ES512 |
JwtKey::FromPem(pem) with a P-256, P-384, or P-521 key |
| EdDSA | EdDSA |
JwtKey::FromPem(pem) with an Ed25519 key |
A private-key PEM both signs and verifies, a public-key PEM only verifies. JwtKey::FromJwk(json) loads a single JSON Web Key (RFC 7517) of type oct, RSA, EC, or OKP (Ed25519). A key that fails to load is invalid (valid() is false) rather than throwing.
Claim validation¶
JwtVerifyOptions selects the checks applied after the signature is verified:
| Option | Default | Check |
|---|---|---|
allowedAlgorithms |
empty: any algorithm of the key's family | The token's alg must be in the set. |
validateExpiration, validateNotBefore |
true |
exp must be in the future and nbf in the past. |
requireExpiration |
false |
Reject a token without exp. |
leeway |
0 s | Clock skew tolerated by the exp and nbf checks. |
clock |
now | Reference time of the temporal checks. Set it to test with a fixed time. |
issuer, subject |
empty: not checked | iss and sub must be present and equal. |
audience |
empty: not checked | aud, a string or an array, must contain this value. |
DecodedJwt exposes the registered claims through typed accessors: issuer(), subject(), audiences() and hasAudience(), jwtId(), expiresAt(), notBefore(), issuedAt(), and the header's algorithm(), keyId(), and type(). An absent claim is an empty view or a 0 date. payloadJson() returns the verified payload, to parse application claims, for instance with Glaze.
Issuer and audience checks are security decisions: a token signed by a trusted key but meant for another service must be rejected, so set audience for every service that accepts third-party tokens.
Key sets (JWKS)¶
Identity providers publish their public keys as a JWK Set, {"keys":[...]}, typically at a jwks_uri. Jwks parses it and selects the verification key from the token's kid header:
#include <aeronet/jwks.hpp>
#include <aeronet/jwt.hpp>
#include <string_view>
const std::string_view jwksJson = R"({"keys":[]})"; // fetched from the provider's jwks_uri
const std::string_view token = "...";
const Jwks keys(jwksJson); // unsupported keys are skipped; an unparseable document leaves the set empty
JwtVerifyOptions options;
options.issuer = "https://issuer.example.com/";
options.audience = "orders-api";
options.requireExpiration = true;
JwtError err = JwtError::None;
const DecodedJwt decoded = keys.tryDecode(token, options, err);
When the token has no kid and the set holds exactly one key, that key is used. A kid that matches no key fails with KeyMismatch. Parsing is transport-agnostic: fetch the document with the HTTP client or any other means, cache it, and refresh it when tokens start carrying an unknown kid, as providers rotate keys.
Protect routes with middleware¶
A request middleware can authenticate every request before it reaches the handlers:
#include <aeronet/jwt.hpp>
#include <string_view>
Router router;
router.addRequestMiddleware([](HttpRequestView& request) {
static const JwtKey key = JwtKey::Hmac("a-long-random-secret-from-a-secret-store");
JwtVerifyOptions options;
options.allowedAlgorithms = JwtAlgorithmSet{JwtAlgorithm::HS256};
options.audience = "orders-api";
static constexpr std::string_view kBearer = "Bearer ";
const std::string_view authorization = request.headerValueOrEmpty("authorization");
JwtError err = JwtError::None;
if (!authorization.starts_with(kBearer) ||
!Jwt::tryDecode(authorization.substr(kBearer.size()), key, options, err)) {
return MiddlewareResult::ShortCircuit(
HttpResponse(http::StatusCodeUnauthorized).headerAddLine("www-authenticate", "Bearer"));
}
return MiddlewareResult::Continue();
});
Verification runs on the event-loop thread. RSA and ECDSA signatures cost much more to verify than HMAC ones: include them in the CPU budget of a server that authenticates every request.
Security posture¶
- The unsecured
alg: none(RFC 7518 §3.6) is always rejected, so a token with a stripped signature never verifies. - A key is only used with algorithms of its own family: an HMAC key cannot verify an
RS256token (KeyMismatch). This rules out the classic confusion where an RSA public key is used as an HMAC secret.allowedAlgorithmsnarrows the accepted set further. - The signature is verified before any claim is parsed or trusted.
- A
critheader that names an extension aeronet does not implement is rejected (RFC 7515 §4.1.11). - HMAC signatures are compared in constant time (
CRYPTO_memcmp).
Error model¶
The module never throws. JwtKey factories return an invalid key, Jwt::encode() returns an empty string, and tryDecode() returns an invalid DecodedJwt and sets a JwtError. Every failure is also logged. ToString(JwtError) describes a reason.
JwtError |
Cause |
|---|---|
Malformed |
Not three base64url segments, or invalid header or claims JSON. |
UnsupportedAlg |
Missing, unknown, or none algorithm. |
AlgNotAllowed |
Algorithm outside allowedAlgorithms. |
KeyMismatch |
Key family incompatible with the algorithm, or no key for the kid. |
InvalidSignature |
Signature does not verify. |
Expired, NotYetValid, MissingExpiration |
Temporal checks. |
IssuerMismatch, AudienceMismatch, SubjectMismatch |
Claim checks. |
Limitations¶
- JWE (encrypted tokens) is not supported.
Jwksparses a document but does not fetch or cache it.
Tests¶
aeronet/jwt/test/: jwt-roundtrip_test.cpp covers every algorithm, jwt-claims_test.cpp the claim checks, jwt-decode-errors_test.cpp the rejection paths (alg: none, crit, algorithm and key mismatch), and jwt-jwk_test.cpp JWK and JWKS parsing.