Skip to content

Comments

Added entitlements JWT endpoint#26523

Open
9larsons wants to merge 5 commits intomainfrom
jwt-entitlement-endpoint
Open

Added entitlements JWT endpoint#26523
9larsons wants to merge 5 commits intomainfrom
jwt-entitlement-endpoint

Conversation

@9larsons
Copy link
Contributor

@9larsons 9larsons commented Feb 20, 2026

ref https://ghost.slack.com/archives/C018EKC56JF/p1771527734858719

The existing JWT is used for identity and only contains the email address. It has a TTL of 10 minutes which is reasonable for the use case but little else.

We have another use case where integrations often want to validate the source, but are left with no option but to hit the members Admin API endpoint with an Admin API key because the email isn't sufficient to know if the member has access to a tier, is paid, isn't specific to email (which could change), etc., all of which are used for integrations.

This endpoint would still require the members session cookie and has the following props, e.g.:

 sub: darylmayer880479@example.com
 scope: members:entitlements:read
 member_uuid: 629c5190-f068-4f49-b533-00d7477a7aed
 paid: false
 active_tier_ids: []
 jti: present
 iat: 1771555398
 exp: 1771555518
 aud/iss: http://localhost:2368/members/api
 Lifetime: 120s (2 minutes)

ref https://ghost.slack.com/archives/C018EKC56JF/p1771527734858719

The existing JWT is used for identity and only contains the email address. It has a TTL of 10 minutes which is reasonable for the use case.

We have another use case where integrations often want to validate the source, but are left with no option but to hit the members Admin API endpoint with an Admin API key because the email isn't sufficient to know if the member has access to a tier, is paid, isn't specific to email (which could change), etc., all of which are used for integrations.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 20, 2026

Walkthrough

Adds member entitlement-token support: TokenService gains encodeEntitlementToken (builds RS512-signed JWT with claims scope: members:entitlements:read, sub, member_uuid, paid, active_tier_ids, jti, iat, exp, 5-minute expiry, uses crypto.randomUUID() for jti); Members API exposes getMemberEntitlementToken(transientId) to produce tokens from a member’s subscriptions; MembersSSR and middleware add helpers/endpoints to obtain tokens from session; new GET /api/entitlements route; unit and e2e tests added.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Added entitlements JWT endpoint' accurately and concisely describes the main change—introducing a new JWT endpoint for entitlements—which is clearly reflected across all modified files.
Description check ✅ Passed The description is directly related to the changeset, explaining the rationale, use case, and expected JWT claims for the new entitlements endpoint, which aligns with the implemented changes across members API, token service, and middleware.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jwt-entitlement-endpoint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@9larsons 9larsons changed the title Added entitlements jwt endpoint Added entitlements JWT endpoint Feb 20, 2026
Copy link
Member

@cathysarisky cathysarisky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would solve a number of cases (only some of which I wrote) where an external integration uses the jwt to validate who the member is (based on email), but then has to use an admin api key for whatever information is needed. I'd love to reduce the need to store an admin api key when all I need is to read a little information about the member, like a non-changing ID, or what tier's privs they should get.

@9larsons 9larsons requested a review from rob-ghost February 20, 2026 13:20
@9larsons 9larsons marked this pull request as ready for review February 23, 2026 15:37
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
ghost/core/core/server/services/members/members-ssr.js (1)

209-212: Missing JSDoc on both new methods — inconsistent with the rest of the class.

Every other _private and public method in MembersSSR carries JSDoc. The new _getMemberEntitlementToken and getEntitlementTokenForMemberFromSession are the only ones without it.

📝 Proposed JSDoc additions
+    /**
+     * `@method` _getMemberEntitlementToken
+     *
+     * `@param` {string} transientId
+     *
+     * `@returns` {Promise<JWT>} entitlement token
+     */
     async _getMemberEntitlementToken(transientId) {
         const api = await this._getMembersApi();
         return api.getMemberEntitlementToken(transientId);
     }
+    /**
+     * `@method` getEntitlementTokenForMemberFromSession
+     *
+     * `@param` {Request} req
+     * `@param` {Response} res
+     *
+     * `@returns` {Promise<JWT>} entitlement token
+     */
     async getEntitlementTokenForMemberFromSession(req, res) {

Also applies to: 331-341

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/members/members-ssr.js` around lines 209 -
212, Add JSDoc comments for the two new methods to match the rest of MembersSSR:
add a JSDoc block above async _getMemberEntitlementToken(transientId) describing
the method (private helper to retrieve an entitlement token via
this._getMembersApi), document the transientId parameter type and purpose, and
the Promise return type; do the same for
getEntitlementTokenForMemberFromSession(session) (describe that it extracts
member info from a session and returns an entitlement token, document the
session parameter and return Promise<string|null> or appropriate type). Ensure
tags (`@private` or `@public` as appropriate, `@param`, `@returns`) and wording/style
follow existing JSDoc conventions used in MembersSSR.
ghost/core/test/e2e-frontend/members.test.js (1)

122-136: Strengthen active_tier_ids assertion and add a paid-member coverage case.

Two gaps in the current test:

  1. Line 134 only asserts the field is an array. For member1@test.com (free member) it should always be [] — assert the exact value to prevent silent regressions.

  2. There is no test covering a paid member, which is the only scenario where activeTierIds is non-empty and where isActiveSubscriptionStatus is actually invoked. Given the concern about that method's existence (see members-api.js), a paid-member scenario is the critical missing coverage.

♻️ Tighten existing assertion
-            assert(Array.isArray(decodedToken.active_tier_ids));
+            assert.deepEqual(decodedToken.active_tier_ids, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/e2e-frontend/members.test.js` around lines 122 - 136, Update
the existing test "returns an entitlement token for a valid member session" to
assert that decodedToken.active_tier_ids is exactly [] for the free member
returned by loginAsMember('member1@test.com') (use jwt.decode(res.text) as
already done), and then add a separate test case that logs in a paid member
(e.g., loginAsMember for a paid account or create a paid member fixture), calls
request.get('/members/api/entitlements'), decodes the token with jwt.decode, and
asserts decodedToken.paid is true and decodedToken.active_tier_ids is non-empty
(and contains the expected tier UUID(s)); keep using the same decodedToken field
checks (sub, scope, member_uuid, exp-iat) to validate the paid scenario as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/members/members-ssr.js`:
- Around line 209-212: Add JSDoc comments for the two new methods to match the
rest of MembersSSR: add a JSDoc block above async
_getMemberEntitlementToken(transientId) describing the method (private helper to
retrieve an entitlement token via this._getMembersApi), document the transientId
parameter type and purpose, and the Promise return type; do the same for
getEntitlementTokenForMemberFromSession(session) (describe that it extracts
member info from a session and returns an entitlement token, document the
session parameter and return Promise<string|null> or appropriate type). Ensure
tags (`@private` or `@public` as appropriate, `@param`, `@returns`) and wording/style
follow existing JSDoc conventions used in MembersSSR.

In `@ghost/core/test/e2e-frontend/members.test.js`:
- Around line 122-136: Update the existing test "returns an entitlement token
for a valid member session" to assert that decodedToken.active_tier_ids is
exactly [] for the free member returned by loginAsMember('member1@test.com')
(use jwt.decode(res.text) as already done), and then add a separate test case
that logs in a paid member (e.g., loginAsMember for a paid account or create a
paid member fixture), calls request.get('/members/api/entitlements'), decodes
the token with jwt.decode, and asserts decodedToken.paid is true and
decodedToken.active_tier_ids is non-empty (and contains the expected tier
UUID(s)); keep using the same decodedToken field checks (sub, scope,
member_uuid, exp-iat) to validate the paid scenario as well.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6d208e1 and 948fbeb.

📒 Files selected for processing (7)
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/token-service.js
  • ghost/core/core/server/services/members/members-ssr.js
  • ghost/core/core/server/services/members/middleware.js
  • ghost/core/core/server/web/members/app.js
  • ghost/core/test/e2e-frontend/members.test.js
  • ghost/core/test/unit/server/services/members/members-api/services/token-service.test.js

Copy link
Contributor

@rob-ghost rob-ghost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What are the alternatives considered and why is the JWT approach the best one?

Question: What's the blast radius of this change? Goes this affect any of the following:

  1. Ghost(pro)
  2. Self-hosters
  3. Clients (first-party, third-party)
  4. Other teams - do any docs / schemas need updating?

Question: Have we considered the security of this? If the JWT was exposed and we're sharing membership subscription status, is that a concern?

return tokenService.encodeEntitlementToken({
sub: member.email,
memberUuid: member.uuid,
paid: member.status !== 'free',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Should we derive paid from activeTierIds? Could they disagree (perhaps in a comp'd scenario where they're not free, but have no active tiers, depending on how comps are implemented)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They should already be in agreement, afaict. If someone is comped they'll have an active tier id, and anyone free should have an empty active tier id.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I /think that a subscription with cancel_at_period_end:true still has status:active, correct? Definitely want those members to be "paid" until the subscription actually cancels.

@cathysarisky
Copy link
Member

"Question: Have we considered the security of this? If the JWT was exposed and we're sharing membership subscription status, is that a concern?"

Not my PR, but I'll comment here. If a user has a membership somewhere embarrassing and that fact leaks, that's bad. The existing /members/api/session already has the member's email and that a membership exists. Is it a marginally worse leak if it's a paid membership? I guess arguably?

But... in the current situation, every integration that needs access to any membership data beyond what's in the current JWT is asking the site owner to give them unscoped admin api access. There are a lot of admin API keys out there that don't need to be, because there's not currently another way to figure out basic user entitlements. The blast radius on a hypothetical admin api key leak from an integration is significant, and would include the ability to exfiltrate all membership data, send phishing/malware emails, deface the site, etc.

In the absence of being able to do tightly-scoped keys, I think this is a net security enhancement.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/core/server/services/members/members-api/services/token-service.js (1)

39-51: aud == iss may trip up external integration verifiers

Both audience and issuer are set to this._issuer (the Ghost members API URL). For the existing identity token this is fine since it's consumed within Ghost, but the entitlement token is explicitly designed for external integrations to independently verify. JWT libraries that strictly enforce the aud claim will require callers to configure this._issuer (e.g. https://example.com/members/api) as the expected audience — which is an unusual value for a token addressed to a third-party integration.

If the token is ultimately meant to be verified by external services (Discord bots, membership-gated platforms, etc.), consider either: (a) making the audience configurable per-call, or (b) documenting that verifiers must set audience: <ghost-site-url>/members/api to accept the token.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/members/members-api/services/token-service.js`
around lines 39 - 51, The JWT audience is currently set to this._issuer in the
jwt.sign call (the entitlement token block using jwk.kid, this._privateKey,
algorithm 'RS512'), which forces external verifiers to expect the Ghost URL as
aud; change the jwt.sign invocation to accept a configurable audience parameter
(e.g., pass an optional audience arg into the entitlement token generation
method or options object) and use that value for the audience claim instead of
always this._issuer, falling back to a sensible default if not provided, and
update the entitlement-token generation method signature and any callers to pass
the intended external audience when issuing tokens.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/services/members/members-api/services/token-service.js`:
- Line 45: The jti claim is being set with crypto.randomUUID() in
token-service.js but there's no server-side blocklist/replay-protection
implemented; add a short clarifying comment next to the jti assignment (where
jti: crypto.randomUUID() is set in the token generation logic) stating that jti
is used for observability/audit only and that no replay protection/blocklist is
in place (and optionally note how to add one later, e.g., store jti in a
short-lived blocklist on logout/revoke). Ensure the comment mentions the symbols
jti and crypto.randomUUID() so future contributors don't assume replay
protection is provided.

---

Nitpick comments:
In
`@ghost/core/core/server/services/members/members-api/services/token-service.js`:
- Around line 39-51: The JWT audience is currently set to this._issuer in the
jwt.sign call (the entitlement token block using jwk.kid, this._privateKey,
algorithm 'RS512'), which forces external verifiers to expect the Ghost URL as
aud; change the jwt.sign invocation to accept a configurable audience parameter
(e.g., pass an optional audience arg into the entitlement token generation
method or options object) and use that value for the audience claim instead of
always this._issuer, falling back to a sensible default if not provided, and
update the entitlement-token generation method signature and any callers to pass
the intended external audience when issuing tokens.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 948fbeb and 0e6a152.

📒 Files selected for processing (4)
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/token-service.js
  • ghost/core/test/e2e-frontend/members.test.js
  • ghost/core/test/unit/server/services/members/members-api/services/token-service.test.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/test/e2e-frontend/members.test.js
  • ghost/core/test/unit/server/services/members/members-api/services/token-service.test.js

scope: 'members:entitlements:read',
paid,
active_tier_ids: activeTierIds,
jti: crypto.randomUUID()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

jti is present but there is no replay-protection backing it

crypto.randomUUID() provides a unique jti per token, which is good for auditability. However, without a server-side jti blocklist, the claim offers no actual replay-protection within the 5-minute validity window. This is fine if jti is intentionally included only for observability/logging purposes, but it would be worth a brief comment to set expectations and prevent a future contributor from assuming replay protection is already in place.

📝 Suggested clarifying comment
         return jwt.sign({
             sub,
             kid: jwk.kid,
             scope: 'members:entitlements:read',
             paid,
             active_tier_ids: activeTierIds,
-            jti: crypto.randomUUID()
+            jti: crypto.randomUUID() // unique token id for auditability; no server-side blocklist, so replay protection within the 5m window is not enforced
         }, this._privateKey, {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/members/members-api/services/token-service.js`
at line 45, The jti claim is being set with crypto.randomUUID() in
token-service.js but there's no server-side blocklist/replay-protection
implemented; add a short clarifying comment next to the jti assignment (where
jti: crypto.randomUUID() is set in the token generation logic) stating that jti
is used for observability/audit only and that no replay protection/blocklist is
in place (and optionally note how to add one later, e.g., store jti in a
short-lived blocklist on logout/revoke). Ensure the comment mentions the symbols
jti and crypto.randomUUID() so future contributors don't assume replay
protection is provided.

Copy link
Member

@cathysarisky cathysarisky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the ideal (from an integrations standpoint) jwt would contain both the uuid and the member email. Integrations may need either or both, and it'd be annoying to have to get both this jwt and the session jwt to get them. I don't have any strong opinion on which one occupies the 'sub' spot.

return tokenService.encodeEntitlementToken({
sub: member.email,
memberUuid: member.uuid,
paid: member.status !== 'free',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I /think that a subscription with cancel_at_period_end:true still has status:active, correct? Definitely want those members to be "paid" until the subscription actually cancels.

This reverts commit 21dbff8.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
ghost/core/core/server/services/members/members-api/services/token-service.js (1)

47-47: jti has no server-side replay protection backing it.

jti can be used to prevent a JWT from being replayed, but without a server-side blocklist there is no actual enforcement within the 5-minute window. If this is intentional (i.e., jti is included only for auditability/traceability), a brief inline comment would prevent future contributors from assuming replay protection is already in place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/members/members-api/services/token-service.js`
at line 47, The jti field (jti: crypto.randomUUID()) in token-service.js is
currently not backed by any server-side replay/blocklist; either implement
server-side enforcement (e.g., persist jti to a short-lived blocklist store like
Redis on revoke and check it during token validation) or, if jti is only for
audit/traceability, add a clear inline comment next to the jti property stating
that no replay protection is enforced and why (and reference the intended
TTL/validation behavior); locate the jti occurrence (the jti:
crypto.randomUUID() line) in token-service.js and apply one of these two fixes
so future contributors won’t assume replay protection exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@ghost/core/core/server/services/members/members-api/services/token-service.js`:
- Line 47: The jti field (jti: crypto.randomUUID()) in token-service.js is
currently not backed by any server-side replay/blocklist; either implement
server-side enforcement (e.g., persist jti to a short-lived blocklist store like
Redis on revoke and check it during token validation) or, if jti is only for
audit/traceability, add a clear inline comment next to the jti property stating
that no replay protection is enforced and why (and reference the intended
TTL/validation behavior); locate the jti occurrence (the jti:
crypto.randomUUID() line) in token-service.js and apply one of these two fixes
so future contributors won’t assume replay protection exists.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0e6a152 and 0affcd8.

📒 Files selected for processing (4)
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/token-service.js
  • ghost/core/test/e2e-frontend/members.test.js
  • ghost/core/test/unit/server/services/members/members-api/services/token-service.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • ghost/core/test/e2e-frontend/members.test.js
  • ghost/core/test/unit/server/services/members/members-api/services/token-service.test.js

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants