New Ory Agent Security is now live! Claim your complimentary test drive. Get Started!

Skip to main content

Cryptographic key management and JSON Web Key Sets (JWKS)

Cryptographic keys play a vital role in securing the transmission of sensitive information. In the context of OAuth2 and OpenID Connect, Ory Hydracryptographic keys are used to sign and verify ID tokens and JWT access tokens.

The two most commonly used cryptographic keys in Ory are RS256 and ES256. RS256 is an asymmetric cryptographic algorithm that generates a public key and a private key. The private key is used to sign the token, while the public key is used to verify its signature. ES256, on the other hand, is a variant of the elliptic curve algorithm, which is more efficient than RSA.

JSON Web Keys​

JSON Web Keys (JWKs) are a JSON data structure that represents a cryptographic key, as specified in IETF RFC7517.

ItemDescription
JSON Web Key (JWK)A JSON object that represents a cryptographic key.
JSON Web Key Set (JWKS)A JSON object that represents a set of JSON Web Key(s).

JWKs are used to represent public and private keys in a standard format that is widely supported across all languages. Here is an example of a JWKS with both private and public keys included:

{
"keys": [
{
"p": "8cs6LVWfWM3_TOQZdNWG09sqq8qGbuSejp3rcvDedVh_NAO9D5byE7cpdM2_4_enh1wXoUzzpL0MSHFLAAErJywKLUgyGmjdmJdA7IFuOV4lPNydcSuyyHm4pXVSc_ZtB0MfVPdAh1TO5zyjkk5IbIC8IYOICI1dxu8namdP5MM",
"kty": "RSA",
"q": "uK9v3Hp3X_FESMl3Tbv1ZF-7-oAwdpSq_hMnzb0CCVJ1nVK7cs4RtYhZoVLDlPg98oe35HGjemdrk_WVduUH3H2wbbK0bE9v_yG-WPor4GPhxmmw8e7KV0qkOK3y2x8gC0P2IlY7PpuxfOIHl-z9PFaddfXxQaNOym_naiK1jnc",
"d": "cGVslzyvoWR487B2gXnrg3MPFFFpyD4a7epTKFa7baGd_5oBxHDgZrZcYW6wrlHNuN_ZDXucNneZeg7m5ZLUG6Uz9cYh7aBmOXiAU3Ag7ImFEVMSIKUHSGq83eKsLS8hiowEx9LeinGHr8gEHYJ9JqYV8yZOuc0_V3MQuZnCi6Xg_WmYRN8eMBV8jPKIILQX10ifrgkVSF3xXi11jN1fUiC17xCRyUArWM7c22CfONhxIXp4inGzJjoNMU7BhLJnpdgBpm9RCmNESRP2U0Yhd659upFv9NFFWmSILTwFkdYW5puaVfkHBIZV7_g5OJV7DsE2Cti5jv3SLk4CyCj68Q",
"e": "AQAB",
"use": "sig",
"kid": "ory-example",
"qi": "4Ji4_LTZURiRJBp72ULUbEIukrXwigrGKqIMKA7M2fYB6PlZ5RjxNsdGrTttaMzKyHdDPQWY01fBNzWvaZCNndUu-PsjDj2tO0a-EfRys4onIeV0srSfk7QXlH-u-gCqYulEvMDXSDrzjW8HBq4n3Z94GeZxa5kE0XD13qf89NU",
"dp": "ofZKivFuonKiD2Q_NQaOoLyPEbHAaOmU190qSLzVlm7oDfRvINEwaEppZ4cmgVJzknT6kx5TmcbUQnY5EdC2ki-qxXg1r4EM5lhysbllFuJcOS9h-tuVjzoRmCtFRs4LbDDm_Of9_mitizEQNEFhu-RjoGNVrLzc0xOBKIH5fzc",
"alg": "RS256",
"dq": "H71OzSZi46M0KAovrbVSu_hT9v4W1hpAtL-YBJyp_-4i9nGkc1uE4ZzYQohVwoFTLB409VauULf7XgdDs5Yy3qrfKksfBMo2JjOnYeVEqyCfSZkaZsmyDoRuaqtCZHQZ7rW0VDxbnCvnud2ijnKVJsx_7SjiWHR3cwT-UVg7uYs",
"n": "rm_FZLcTUKdiCnv5zc5284DBQ2RO0f-VLpD4CcJ6Y3Po0zYoMiniOCdmTn1I5klau6BfVQWpDfdqV-G-HhRhLpdDy30Zs-t1veN-YxXgBOnF6neqww5tivwtJ--SS5S2m4UyiNxqlWy4-1FttpCKwu-Dm8d2Q7ppUal6wQojGOnCje8P499a0x9JjMZbh0DcUke2mn_ScmVTV8IEC7caMyo3D_HVdaMuNDN2N2O-7fRUJTVn8pgsjUfw1xP8tB-8-k6rK07X9yi_-oUyXqaqj8IhCPNMOc1UaQbrY3vvdMarQQrykkyXDPp6IL4vA3dw8q46BJvfLRsOCa1g-uaApQ"
}
]
}
note

If you are interested in experimenting with a JWK generator, try the mkjwk generator. Use the service for experimentation only. Don't use it to generate keys for the production environment.

Public key only​

Here is an example of a JWKS including only the public key:

{
"kty": "RSA",
"e": "AQAB",
"use": "sig",
"kid": "ory-example",
"alg": "RS256",
"n": "rm_FZLcTUKdiCnv5zc5284DBQ2RO0f-VLpD4CcJ6Y3Po0zYoMiniOCdmTn1I5klau6BfVQWpDfdqV-G-HhRhLpdDy30Zs-t1veN-YxXgBOnF6neqww5tivwtJ--SS5S2m4UyiNxqlWy4-1FttpCKwu-Dm8d2Q7ppUal6wQojGOnCje8P499a0x9JjMZbh0DcUke2mn_ScmVTV8IEC7caMyo3D_HVdaMuNDN2N2O-7fRUJTVn8pgsjUfw1xP8tB-8-k6rK07X9yi_-oUyXqaqj8IhCPNMOc1UaQbrY3vvdMarQQrykkyXDPp6IL4vA3dw8q46BJvfLRsOCa1g-uaApQ"
}

Key requirements​

Ory Hydra Authorization rejects a JSON Web Key that breaks one of these rules. This applies to keys written through the admin key API, keys in an OAuth 2.0 client's jwks or at its jwks_uri, and keys of trust relationships for the JWT bearer grant:

  • An RSA key is at most 4096 bits long.
  • Base64url-encoded key values, such as n, e, d, x, and k, have no padding, as RFC 7518 requires. A value that ends in = is invalid.
  • In an RSA private key, p multiplied by q equals n.
  • In an Ed25519 key, x is 32 bytes long and isn't a low-order point. In an Ed25519 private key, d is the 32-byte seed, and x is the public key that belongs to it.
  • An HMAC key ("kty": "oct") that signs or verifies a token is at least as long as the hash output: 32 bytes for HS256, 48 bytes for HS384, and 64 bytes for HS512.

Default cryptographic keys in Ory​

By default, Ory OAuth2 and OpenID Connect generates two cryptographic keys for each Ory Network project: one for ID Tokens and one for JWT Access Tokens. The keys are stored securely and encrypted at rest using AES256-GCM and the system secret.

How to rotate cryptographic keys​

It is recommended to rotate cryptographic keys regularly to ensure maximum security. To rotate cryptographic keys in Ory, you can use the JSON Web Keys API, which provides endpoints for generating and managing JWKs. A rotation adds a new key to the key set, which signs right away, and then retires the old key.

Rotate OpenID Connect ID token keys​

ory create jwks \
--alg RS256 \
hydra.openid.id-token

Rotate OAuth 2.0 access token JWT keys​

ory create jwks \
--alg RS256 \
hydra.jwt.access-token

Which key signs​

Ory Hydra Authorization signs with the newest private key in a key set, which is the private key that was added to the set last. Writing a key that the set already holds, with PUT /admin/keys/{set} or PUT /admin/keys/{set}/{kid}, keeps its place, so an older key doesn't start signing again. To make an older key sign again, replace the newer keys with their public halves, as described in Writing back what you read. Unlike deleting the newer keys, this keeps the tokens they signed valid.

hydra import jwk adds the keys of several files in the order you give them. The private key of the last file signs, unless the key set already holds its kid. A file that holds only a public key never signs.

On MySQL, Ory Hydra Authorization stores the time a key was added in whole seconds, so keys added within the same second have no defined order. Add the key that must sign at least one second after the other keys, for example with its own hydra import jwk command. In a key set stored in a Hardware Security Module (HSM), the module decides the order of the keys instead.

Tokens signed before a rotation​

Ory Hydra Authorization signs new tokens with the newest key in a key set. A token signed with an older key stays valid for as long as the key set still holds that key, because Ory Hydra Authorization checks each token with the key that the token's kid header names. This applies to:

  • JWT access tokens sent to the token introspection endpoint, POST /admin/oauth2/introspect, the userinfo endpoint, /userinfo, and the verifiable credentials endpoint, POST /credentials.
  • The id_token_hint parameter of the authorization endpoint, /oauth2/auth, and the logout endpoint, /oauth2/sessions/logout.

Ory Hydra Authorization checks a token without a kid header, or one that the key named by its kid doesn't verify, with the current signing key.

Applications that verify tokens themselves find every key of the set at /.well-known/jwks.json.

To stop accepting tokens signed with an old key, delete that key with DELETE /admin/keys/{set}/{kid}. With the JWK cache enabled, this takes effect when the cache entry expires. See Caching of JWKs. To keep accepting them but remove the old private key, retire the old key.

When keys are stored in a Hardware Security Module (HSM), creating a key with POST /admin/keys/{set} replaces the key set. Tokens signed with the previous key then fail these checks.

Retire the old key​

After a rotation, the old key still holds its private half. Replace the old key with its public half, so that nobody can sign with it anymore while the tokens it signed keep verifying. Set OLD_KID to the kid of the old key, then run:

ory get jwk hydra.openid.id-token --public --format json \
| jq --arg kid "$OLD_KID" '.keys[] | select(.kid == $kid)' > old-key.public.json
ory import jwk hydra.openid.id-token old-key.public.json

ory import jwk stores each key under the kid in its file, so the file has to carry the kid of the old key. For a file without a kid, such as a PEM file, ory import jwk generates a new key ID and adds a second key instead, and the old key keeps its private half.

Publish a new key before it signs​

A new private key signs as soon as you add it to the key set. An application that caches /.well-known/jwks.json rejects tokens signed with the new key until it fetches the key set again. To give these applications time, publish the public half of the new key first, and add its private half once they have picked it up:

  1. Generate the new key in a separate key set, save both halves to files, and delete that key set:

    ory create jwk scratch-set new-key --alg RS256
    ory get jwk scratch-set --format json > new-key.json
    ory get jwk scratch-set --public --format json > new-key.public.json
    ory delete jwk scratch-set
  2. Import the public half. /.well-known/jwks.json now lists the new key, and the current key keeps signing:

    ory import jwk hydra.openid.id-token new-key.public.json
  3. Wait at least as long as the applications cache /.well-known/jwks.json.

  4. Import the private half. The key set already holds new-key, so the key keeps its place as the newest key in the set and starts signing:

    ory import jwk hydra.openid.id-token new-key.json
  5. Delete new-key.json and retire the old key.

Don't add other keys to the key set between steps 2 and 4, because the newest private key signs. Step 1 reads the private key, which requires jwks.admin_api.expose_private_keys to be true, the default. See Private keys in admin API responses. With the JWK cache enabled, each step takes effect when the cache entry expires.

Retiring and publishing keys this way doesn't apply to a key set stored in a Hardware Security Module (HSM). Ory Hydra Authorization reads such a key set only from the HSM, so keys imported into it have no effect.

The role of /.well-known/jwks.json​

The /.well-known/jwks.json file is a standard endpoint that contains the public keys for verifying ID tokens and JWT access tokens. Ory OAuth2 and OpenID Connect exposes this endpoint by default for each Ory Network project at:

https://$PROJECT_SLUG.projects.oryapis.com/.well-known/jwks.json

The document contains public key material only. A symmetric key is omitted from the document, even when its keyset is broadcast.

Add custom keys​

To broadcast a created keyset on /.well-known/jwks.json it needs to be added to the OAuth2 configuration.

To add a custom keyset to the OAuth2 configuration, use the following command:

ory patch oauth2-config --project <project-id> --workspace <workspace-id> \
--add '/webfinger/jwks/broadcast_keys/-="custom_keyset"'

For more information on how to create a custom keyset, see the ory create jwk command.

Write a single key​

PUT /admin/keys/{set}/{kid} creates or updates the key with the key ID from the URL path. If the request body has a kid, it must equal the key ID in the path. Otherwise, Ory Hydra Authorization rejects the request with 400 and leaves the key set unchanged. If the request body has no kid, the key is stored under the key ID in the path.

Private keys in admin API responses​

The admin JSON Web Keys API returns private key material for software-backed keys. This applies to GET /admin/keys/{set}, GET /admin/keys/{set}/{kid} and POST /admin/keys/{set}.

This is controlled by jwks.admin_api.expose_private_keys, which defaults to true. In a self-hosted deployment, set it to false to omit private key material from these responses:

hydra.yml
jwks:
admin_api:
expose_private_keys: false

Do this for defense in depth if you do not read private keys through the admin API. Anyone with admin API access can otherwise read the keys that sign your ID and access tokens.

Keys are always stored in full. A key with no public representation — a symmetric key, whose only key material is the secret itself — is omitted from the response entirely, so a response can come back with an empty keys array.

The setting does not apply to PUT /admin/keys/{set} and PUT /admin/keys/{set}/{kid}. Those echo the key material the request carried, which tells the caller nothing they did not already hold.

Writing back what you read​

Because reads return public keys only, writing a response back unchanged would replace your signing keys with their public halves and destroy the private material. To prevent that, PUT /admin/keys/{set} and PUT /admin/keys/{set}/{kid} reject a request that would leave the key set without any private key, and answer 400 instead.

Key rotation is unaffected, and never requires you to hold private key material. Add the new key first, then retire the old one:

  1. POST /admin/keys/{set} generates a key and adds it to the set. Ory Hydra signs with the newest private key in a set, so this switches signing over immediately.
  2. PUT /admin/keys/{set}/{kid} with the old key's public half discards its private material while keeping the public half, so tokens that are still in circulation continue to verify:
PUT /admin/keys/my-set/old-key
{ "kid": "old-key", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" }

That request is accepted because the key from step 1 keeps a private key in the set. Only a request that would leave no private key at all is rejected.

To drop a key without keeping its public half, use DELETE /admin/keys/{set}/{kid}, and to remove a key set entirely, DELETE /admin/keys/{set}.

Keys backed by a Hardware Security Module are always returned as public keys, regardless of this setting. The public /.well-known/jwks.json endpoint is never affected. It returns public keys only.

Caching of JWKs​

The Ory Hydra Enterprise License (OEL) supports in-memory caching of JWKs to improve performance and reduce latency. It is configurable via the following options:

  • db.jwk_cache.enabled governs whether the cache is enabled or not
  • db.jwk_cache.size is the maximum number of keys to be cached
  • db.jwk_cache.ttl is the time-to-live for cached keys

It's recommended to set a cache TTL to force regular refreshes of the keys. Updates to keys aren't propagated to the cache, so it's possible for tokens to be signed with rotated keys for the cache TTL duration. Clients are expected to accept old keys for a certain period of time. For the same duration, an instance whose cache doesn't hold a new key yet rejects tokens signed with that key, for example when it introspects a JWT access token. Likewise, an instance whose cache still holds a deleted key accepts tokens signed with that key.

Caching of client key sets (jwks_uri)​

Wherever Ory downloads a key set from a client's jwks_uri — private_key_jwt client authentication, JWT client assertions, and signed request objects — the following contract applies:

  • Ory caches a downloaded key set for up to three minutes and serves it from the cache within that window.
  • Ory starts at most one refresh attempt every 15 seconds per jwks_uri, even when an assertion names a key ID that Ory does not know yet. A failed attempt may be retried by the HTTP client within that refresh's timeout.
  • When you rotate keys, publish the new key at jwks_uri at least 15 seconds before signing with it, and keep the retired key published for as long as material signed with it can still arrive.
  • If refreshing fails, Ory keeps serving the previously downloaded keys for up to one hour past their three-minute lifetime, after which requests fail with the refresh error.
  • Publishing an empty key set ({"keys": []}) at jwks_uri revokes every key: Ory adopts it on the next refresh and rejects all assertions immediately after.