JWT Key Management and Rotation

Forgejo uses JSON Web Tokens (JWTs) extensively for various authentication and authorization functions.

This guide introduces to the topic and how to manage signing and verification keys.

Intro

Note: This introduction deliberately simplifies a complex topic. Do not assume that this summary was in any way all you needed to know about it.

In a nutshell, JWTs are tokens which represent an identity and/or grant certain access rights. They usually are valid only for a certain time interval and, as such, can be seen as a kind of “lease”.

Fundamental to the security of JWT based flows is the premise that they can only be forged by a trusted grantee, and not by an external user. If a user could forge JWTs, they could generally assume arbitrary identities and/or grant themselves access rights at will.

This premise is achieved by signing JWTs with (a) cryptographic key(s) which are only known to trusted parties. Various signing algorithms are supported by Forgejo, but the most fundamental difference between them is if they are symmetric or asymmetric. Generally speaking, symmetric algorithms are very fast, but use a shared secret for both signing and validation. In other words, with a symmetric algorithm, any party which has the secret to validate tokens can, by definition, also forge them. So in the common case, symmetric algorithms are a good choice (only) if a single component both issues and validates tokens.

If multiple components are involved, asymmetric algorithms are generally the better choice: They use a private and a public key, where only the private key can issue tokens, but any number of components can validate them using the public key, which can be shared freely.

In both cases, obviously, keeping the symmetric or private key contained to the grantee (here: Forgejo) is of vital importance. Leaked keys should be handled as a major security incident and be responded to by revoking the leaked keys after closing the attack vector.

Configuring JWT signing and verification keys

All modules using JWTs support a subset of the configuration file directives documented in JWT signing key configuration.

Symmetric signing keys for the HS256, HS384 or HS512 algorithm are configured either literally using [pfx]SECRET, or to be read from a file specified using [pfx]SECRET_URI.

Asymmetric signing keys for all other algorithms are configured using [pfx]SIGNING_PRIVATE_KEY_FILE. Note that Forgejo creates the private key file if it does not exist.

The signing algorithm is selected using [pfx]SIGNING_ALGORITHM.

Forgejo always accepts the configured signing key also for verification.

Additional keys accepted for verification can be configured using [pfx]KEYS_ACCEPTED which accepts any number of space delimited <alg>:[<base64>|<uri>] specifications, where <alg> is any algorithm accepted by [pfx]SIGNING_ALGORITHM, <base64> is a literal secret as used with [pfx]SECRET and, alternatively <uri> must be a file: URI, but can contain wildcards.

Wildcards allow to simplify accepting multiple keys, as with [pfx]KEYS_ACCEPTED = RS256:file:jwt/old/*.pem.

For the same reason of simplicity, both public and private keys can be specified using [pfx]KEYS_ACCEPTED. This allows to simply rename a signing key file to an accepted key, enabling easy…

Key Rotation

Rotating keys regularly is generally recommended to limit the potential impact of leaks: For example, if a developer accidentally gained access to key material and then left a team, they would generally be able to go rogue and forge tokens. More realistically, they could later lose the key, in which case a third party might gain this capability.

By regularly rotating keys, the potential impact of such scenarios can be limited in time, hence regular key rotation is generally regarded as good practice.

Seamless key rotation for asymmetric keys happens in two or three stages:

  1. generate a new keypair and make the new public key known to all parties requiring verification. Wait until all relevant parties started accepting the new public key.

    This step can usually be skipped for Forgejo, if only Forgejo accepts its own tokens.

  2. Start signing using the new private key, but continue to accept the old public key until all tokens signed with it have expired.

  3. After the expiration time has passed, stop accepting the old public key.

Let’s illustrate this with an…

oauth2 Key Rotation Example

With the default REFRESH_TOKEN_EXPIRATION_TIME of 730 hours = 30 days, we could establish a rotation scheme with a new keypair introduced every month, removing the old key the month after - if only February did not exist.

So, for the sake of simplicity, let’s assume that we set REFRESH_TOKEN_EXPIRATION_TIME = 648 (= 27 days) to be able to rotate on the same day of each month, always stopping to accept the key from the month before last.

Also let’s assume that only Forgejo is accepting its own tokens, such that we do not need step 1).

Then we end up with a simple rotation scheme:

We start off in January with just one signing key and no verification key in our configuration:

[oauth2]
REFRESH_TOKEN_EXPIRATION_TIME = 648
JWT_SIGNING_ALGORITHM = RS256
JWT_SIGNING_PRIVATE_KEY_FILE = jwt/private/january.pem

With this configuration, Forgejo creates an RSA key in jwt/private/january.pem.

On the first day of February, we introduce a new key, but we need to continue accepting the January key, because refresh tokens generated just before the rotation will remain valid until the 28th of February. We move the January key to a directory jwt/old,

mkdir jwt/old
mv jwt/private/january.pem jwt/old

change the Forgejo configuration to contain

[oauth2]
REFRESH_TOKEN_EXPIRATION_TIME = 648
JWT_SIGNING_ALGORITHM = RS256
JWT_SIGNING_PRIVATE_KEY_FILE = jwt/private/february.pem
JWT_KEYS_ACCEPTED = RS256:file:jwt/old/*.pem

and restart. Forgejo continues to accept the January key now in jwt/old, creates a new February key and signs all new tokens with it.

On the first day of March, we perform the same dance again with the February key, but this time we delete the January key:

mv jwt/private/february.pem jwt/old
rm jwt/old/january.pem

In the config, we change the signing key to be a new one for March and restart:

[oauth2]
REFRESH_TOKEN_EXPIRATION_TIME = 648
JWT_SIGNING_ALGORITHM = RS256
JWT_SIGNING_PRIVATE_KEY_FILE = jwt/private/march.pem
JWT_KEYS_ACCEPTED = RS256:file:jwt/old/*.pem

When coming back up, Forgejo will use a new March key for signing and continue to accept the February key.

This routine can then continue each month. Just never remove an old key before REFRESH_TOKEN_EXPIRATION_TIME hours have passed.

Here we used month names for clarity. In an actual production setup, you would probably want to automate this process and use some numerical timestamp.

Key revocation

To revoke a key, for example after a leak, we simply stop accepting it. This will render all issued tokens invalid, but it is the only way to ensure that a leaked key can not be used to forge tokens.

Revoking is simple: Delete the JWT_SIGNING_PRIVATE_KEY_FILE (and optionally use a new name) and restart.