Convert Ory Sessions to JSON Web Tokens
Ory provides a robust session management system that uses cookies for browser clients and API tokens for API clients. It also supports converting a session into a JSON Web Token (JWT). A JWT can be a better option in scenarios such as:
- Making cross-origin (CORS) requests where including the Ory session cookie or session token is difficult or not possible.
- Representing a signed-in user with a JWT.
- Integrating with third-party services, such as Zendesk SSO JWTs.
- Reducing the number of calls to Ory's APIs.
This guide explains how to convert Ory sessions into JSON Web Tokens (JWTs).
End-to-end example
Let's look at an end-to-end example. This guide assumes that you already have an Ory Network project running. If not, create a new project now. First we need to create a JSON Web Key set and store it locally:
ory create jwk some-example-set \
--alg ES256 --project $PROJECT_ID --format json-pretty \
> es256.jwks.json
Next, we need to create a Jsonnet template that will be used to modify the claims of the JWT:
local claims = std.extVar('claims');
local session = std.extVar('session');
{
claims: {
iss: claims.iss + "/additional-component",
schema_id: session.identity.schema_id,
session: session,
}
}
The easiest way to supply these files to Ory Network is to base64-encode them:
JWKS_B64_ENCODED=$(cat es256.jwks.json | base64 -w 0)
JSONNET_B64_ENCODED=$(cat claims.jsonnet | base64 -w 0)
Next, we configure our Ory Network project's tokenizer templates. The key we choose here is jwt_example_template1
. We supply
that template with the base64-encoded files from above:
ory patch identity-config --project <project-id> --workspace <workspace-id> \
--add '/session/whoami/tokenizer/templates/jwt_example_template1={"jwks_url":"base64://'$JWKS_B64_ENCODED'","claims_mapper_url":"base64://'$JSONNET_B64_ENCODED'","ttl":"10m"}' \
--format yaml
Great! Everything is set up! Let's convert an Ory Session to a JWT:
- Ory JS SDK
- REST API
import { Configuration, FrontendApi } from "@ory/client"
const frontend = new FrontendApi(
new Configuration({
basePath: `https://${process.env.ORY_PROJECT_SLUG}.projects.oryapis.com`,
}),
)
export async function toSessionWithJwt(sessionId: string) {
const session = await frontend.toSession({
tokenize_as: "jwt_example_template1",
})
const jwt = session.tokenized
return jwt
}
curl -X GET \
-H "Cookie: ory_session...=..." \
"https://$PROJECT_SLUG.projects.oryapis.com/sessions/whoami?tokenize_as=jwt_example_template1"
{
// ...
"tokenized": "{the-jwt}"
}
For more details see the API documentation.
To verify the resulting JSON Web Token, export the public key from the JSON Web Key Set and use it to verify the token:
ory get jwk some-example-set \
--public \
--project $PROJECT_ID --format json-pretty \
> es256-public.jwks.json
JSON Web Token templates
Now that you have seen how this feature works in practice, let's look at how to configure it in detail. Before issuing a JWT for a Ory Session, you need to define one or more Ory Session tokenizer templates. A template has a unique key, a claims template, a TTL, and a URL where the cryptographic keys (JSON Web Key Sets) are fetched from:
session:
whoami:
tokenizer:
templates:
jwt_template_1:
jwks_url: base64://... # A JSON Web Key Set (required)
claims_mapper_url: base64://... # A JsonNet template for modifying the claims
ttl: 1m # 1 minute (defaults to 10 minutes)
another_jwt_template:
jwks_url: base64://... # A JSON Web Key Set
JSON Web Token claim mapper
You can customize the JSON Web Token claims by providing a JsonNet template to claims_mapper_url
.
The sub
claim can't be customized and is always set to the Ory Session's IdentityID
.
The template has access to these variables:
claims
: The default claims the token has per default. You can modify these claims but not remove them:jti
: A unique UUID v4 value.iss
: The project's slug url (https://$PROJECT_SLUG.projects.oryapis.com
).exp
: The token's expiry which uses the template'sttl
value.sub
: The Ory Session'sIdentityID
.sid
: The Ory Session'sID
.nbf
: The time when the token becomes valid ("now").iat
: The time when the token was issued at ("now").
session
: Contains the Ory Session's data. See the Ory Session documentation for more information.
The template must return a JSON object. For example:
local claims = std.extVar('claims');
local session = std.extVar('session');
{
claims: {
foo: "baz",
sub: "this can not be overwritten and will always be session.identity.id",
schema_id: session.identity.schema_id,
aal: session.authenticator_assurance_level,
second_claim: claims.exp,
// ...
}
}
JSON Web Token signing key(s)
The jwks_url
must contain a JSON Web Key Set. All common cryptographic algorithms for JSON Web Tokens are supported such as
ES256, RS256, RS512, and others.
Ory recommends to use ES256 or ES512 for signing JSON Web Tokens. Avoid symmetric algorithms such as the HS family (HS256, HS512, etc).
To generate test-keys you can use a service such as mkjwk.org. To generate keys for production, use the Ory CLI:
ory create jwk some-example-set \
--alg ES256 --project $PROJECT_ID --format json-pretty
{
"set": "example-key-set",
"keys": [
{
"alg": "ES256",
"crv": "P-256",
"d": "XdO-4OkdDxsOhU_XwYFAzEg1Z3DfQ8LhwivJeFq-ppo",
"kid": "3045631b-95a8-433c-ab54-93fa52a55ea8",
"kty": "EC",
"use": "sig",
"x": "AAYxrjPNt6M-XBY1H57Mc_6moiETkg_Cf2egHXPOEGo",
"y": "mNX9UCBa82GNrvIIHFFNxsw-LPKksbwCMoaIybyWMEY"
}
]
}
If the key set contains more than one key, the first key in the list will be used for signing:
{
set: "example-key-set",
keys: [
// This key will be used for signing:
{
alg: "ES256",
// ...
},
{
alg: "ES256",
// ...
},
],
}
Customizing JWT claims with webhooks
Sometimes, Jsonnet scripting is not enough to customize the JWT. A network call to a different service is necessary. A typical use case is adding custom claims to the tokens based on a separate database or business logic.
This is possible by registering a webhook endpoint in the configuration. This is all done in a separate endpoint:
/sessions/whoami-jwt/{templateName}
. Before the token is issued to the client, Ory will call your HTTPS endpoint with
information about the client requesting the token. Note that the template name is mandatory. This way, your application can at
runtime use different template names and thus third-party endpoints at will.
Be aware that this approach is easy but also has numerous disadvantages:
- Added request latency: if the third-party service takes 100 milliseconds to respond to the request, that means that the response
time for the
/sessions/whoami-jwt/{templateName}
increases by that much - If the third-party service is not available, authentication will fail for the user.
Thus, we recommend creating the JWT yourself if you need to add a lot of dynamic claims.
Alternatively and preferably, you can use distributed/aggregated claims.
Your endpoint's response to the webhook will be used to customize the JWT token that Ory issues to the client.
Using webhooks is supported for all flows.
If your endpoint is gated behind authentication, ensure that the configuration contains the correct credentials. They will be sent in the request when contacting the endpoint.
The webhook is called before any other logic is executed. If the webhook execution fails, for example if your endpoint is unreachable or responds with an HTTP error code, the token exchange will fail for the client.
Configuration
Use the Ory CLI to register your webhook endpoint:
- With authentication in header
- With authentication in cookie
- No authentication
ory patch identity-config --project <project-id> --workspace <workspace-id> \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/url="https://my-example.app/token-hook"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/type="api_key"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/config/in="header"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/config/name="X-API-Key"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/config/value="MY API KEY"' \
--format yaml
ory patch identity-config --project <project-id> --workspace <workspace-id> \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/url="https://my-example.app/token-hook"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/type="api_key"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/config/in="cookie"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/config/name="X-Cookie-Name"' \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/auth/config/value="MY SECRET COOKIE"' \
--format yaml
ory patch identity-config --project <project-id> --workspace <workspace-id> \
--add '/session/whoami/tokenizer/templates/jwt_template_1/claims_hook/url="https://my-example.app/token-hook"' \
--format yaml
Or use the YAML configuration:
- With authentication in header
- With authentication in cookie
- No authentication
session:
whoami:
tokenizer:
templates:
jwt_template_1:
jwks_url: base64://... # A JSON Web Key Set (required)
ttl: 1m # 1 minute (defaults to 10 minutes)
claims_hook:
url: https://my-example.app/token-hook
auth:
type: api_key
config:
in: header
name: X-API-Key
value: "MY API KEY"
another_jwt_template:
jwks_url: base64://... # A JSON Web Key Set
# [...]
session:
whoami:
tokenizer:
templates:
jwt_template_1:
jwks_url: base64://... # A JSON Web Key Set (required)
ttl: 1m # 1 minute (defaults to 10 minutes)
claims_hook:
url: https://my-example.app/token-hook
auth:
type: api_key
config:
in: cookie
name: X-Cookie-Name
value: "MY SECRET COOKIE"
another_jwt_template:
jwks_url: base64://... # A JSON Web Key Set
# [...]
session:
whoami:
tokenizer:
templates:
jwt_template_1:
jwks_url: base64://... # A JSON Web Key Set (required)
ttl: 1m # 1 minute (defaults to 10 minutes)
claims_hook:
url: http://svc.example.com/jwt-webhook
another_jwt_template:
jwks_url: base64://... # A JSON Web Key Set
# [...]
Webhook payload
Ory will perform a POST request with a JSON payload towards your endpoint as configured, e.g.
http://svc.example.com/jwt-webhook
.
{
"request_headers": {
"X-Claim-Name": ["a custom claim"]
},
"request_method": "GET",
"request_url": "http://example.com/sessions/whoami-jwt/template_1",
"request_cookies": {},
"session": {
"id": "432caf86-c1d8-401c-978a-8da89133f78b",
"active": true,
"expires_at": "2025-08-22T16:29:26.579741+02:00",
"authenticated_at": "2025-08-21T16:29:26.579741+02:00",
"authenticator_assurance_level": "aal1",
"authentication_methods": [
{
"method": "password",
"aal": "aal1",
"completed_at": "2025-08-21T14:29:26.899803Z"
}
],
"issued_at": "2025-08-21T16:29:26.579741+02:00",
"identity": {
"id": "7458af86-c1d8-401c-978a-8da89133f78b",
"external_id": "external-id",
"schema_id": "default",
"schema_url": "http://localhost/schemas/ZGVmYXVsdA",
"state": "active",
"state_changed_at": "2025-08-21T14:29:26.899788Z",
"traits": {},
"metadata_public": null,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "2025-08-21T16:29:26.900034+02:00",
"organization_id": null
},
"devices": [
{
"id": "00000000-0000-0000-0000-000000000000",
"ip_address": "192.0.2.1:1234",
"user_agent": null,
"location": ""
}
],
"tokenized": "eyJhbGciOiJFUzUxMiIsImtpZCI6ImJjN2Y3YWZjLTY3NDItNDI3Yy1iYjllLTE2NGZlMGY4YjZhNyIsInR5cCI6IkpXVCJ9.eyJhYWwiOiJhYWwxIiwiZXhwIjoxNjc1MjA5NjYwLCJleHRlcm5hbF9pZCI6ImV4dGVybmFsLWlkIiwiZm9vIjoiYmFyIiwiaWF0IjoxNjc1MjA5NjAwLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0LyIsImp0aSI6IjYwZjdjOGU2LTIxYzMtNGExYi1hMTBhLTRjZjUyNmVhOWMzOCIsIm5iZiI6MTY3NTIwOTYwMCwic2NoZW1hX2lkIjoiZGVmYXVsdCIsInNlY29uZF9jbGFpbSI6MTY3NTIwOTY2MCwic2lkIjoiNDMyY2FmODYtYzFkOC00MDFjLTk3OGEtOGRhODkxMzNmNzhiIiwic3ViIjoiZXh0ZXJuYWwtaWQifQ.AXSrbfjtCvufuZEosTeHbS_oo01MiwdaB3ebS96pb9fO51QkC0rQHadY2hC3Ig4SP2x60NMl-Ff5mlEp4QTY9tPIATwFPbXFs-dBCKlZsLk7RA_s2fi4JXTQJU2WbxdHNGn_W3tL4IsTIQLPrrxXd301c72kDc4TVhLX99kIlasheBas"
},
"claims": {
"exp": 1675209660,
"iat": 1675209600,
"iss": "http://localhost/",
"jti": "eecab9dd-86fe-469a-8c04-ed7e103aea1e",
"nbf": 1675209600,
"sid": "432caf86-c1d8-401c-978a-8da89133f78b",
"sub": "7458af86-c1d8-401c-978a-8da89133f78b"
}
}
Fields prefixed with request_
contain information from the client's request to the /sessions/whoami-jwt/{templateName}
endpoint.
The HTTP headers, present in the client request and that are in the allow-list (from the configuration field
clients.web_hook.header_allowlist
), will be forwarded to the webhook endpoint. In the above example, X-Claim-Name
is such a
HTTP header.
Responding to the webhook
When handling the webhook in your endpoint, use the request payload to decide how Ory should proceed in the token exchange with the client.
To accept the token exchange without modification, return a 204
or 200
HTTP status code without a response body.
To deny the token exchange, reply with a 403
HTTP status code.
To accept the claims without modification, return an empty body with a 204 status code.
To modify the claims of the issued tokens and instruct Ory Identities to proceed with the token exchange, return 200
with a JSON
response body containing the claims that the final JWT should contain:
{
"claims": {
"name": "John Doe",
"admin": true
}
}
For the happy path, the response body must be an object containing a key claims
, whose value is a map of string keys to any
value. Each key-value in the claims
object will be added as-is to the final JWT claims.
Responding with any other HTTP status code will abort the token exchange toward the client with an error message. In case of a 40x or 50x response code from your endpoint, a response body of any shape can be sent (for example containing an error message or code) and this response body will be included in the error sent back to the client on a best effort basis.
Updated tokens
Tokens issued by Ory to the client will contain the data from your webhook response:
{
"admin": true,
"exp": 1675209660,
"iat": 1675209600,
"iss": "http://localhost/",
"jti": "d4ef10de-bffd-48cb-a9cf-3cab91d4f5c2",
"name": "John Doe",
"nbf": 1675209600,
"sid": "432caf86-c1d8-401c-978a-8da89133f78b",
"sub": "7458af86-c1d8-401c-978a-8da89133f78b"
}
You cannot override the token subject.