Skip to content

fix: harden JWT signature verification and URL path encoding#386

Merged
gjtorikian merged 5 commits into
mainfrom
updates
May 11, 2026
Merged

fix: harden JWT signature verification and URL path encoding#386
gjtorikian merged 5 commits into
mainfrom
updates

Conversation

@gjtorikian
Copy link
Copy Markdown
Contributor

Summary

  • Verify access-token JWS signatures against the JWKS in SessionManager::decodeAccessToken, with an RS256 allow-list that rejects alg: none, missing/unknown kid, tampered signatures, and tokens lacking a matching JWK. Expiration is now checked only after signature verification.
  • Issuer/audience enforcement is intentionally deferred (TODO) until the documented WorkOS values are confirmed; mirrors current Ruby/Python SDK behavior.
  • Centralize RFC 3986 path-segment encoding in HttpClient::resolveUrl so an untrusted ID interpolated into a path template (e.g. om_xyz?/foo) cannot escape its segment into a new path or query. Pre-encoded triplets from existing rawurlencode callers are preserved (no double-encoding).
  • Stabilize the tampered-signature test by flipping a middle byte of the JWS signature instead of the last base64url char, which could canonicalise back to the original bytes.

Test plan

  • composer test passes (HttpClient + SessionManager suites)
  • Confirm existing services that already rawurlencode IDs are not double-encoded
  • Manually verify a tampered access token is rejected end-to-end
  • Confirm tokens with alg: none or unknown kid are rejected

gjtorikian added 3 commits May 7, 2026 14:38
Centralize RFC 3986 path-segment encoding in HttpClient::resolveUrl so
an untrusted ID interpolated into a path template (e.g. om_xyz?/foo)
stays inside a single segment instead of opening a new path or query.
Existing services that already call rawurlencode are preserved
(percent-encoded triplets are not double-encoded).
Harden SessionManager::decodeAccessToken to verify the JWS signature
against the JWKS published for the supplied client ID before returning
claims. Adds an algorithm allow-list (RS256 only) and rejects tokens
with `alg: none`, missing/unknown `kid`, tampered signatures, or no
matching JWK. Expiration is checked after signature verification.

Issuer/audience enforcement is left as a TODO until the documented
WorkOS values are confirmed; other WorkOS SDKs (Ruby, Python) currently
skip aud verification.
Flipping the last base64url char of the JWT signature could canonicalise
to the original bytes; flip a middle byte instead so the decoded
signature is deterministically different.
@gjtorikian gjtorikian requested review from a team as code owners May 8, 2026 18:27
@gjtorikian gjtorikian requested a review from nicknisi May 8, 2026 18:27
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This PR adds proper JWS signature verification to SessionManager::decodeAccessToken (RS256 allow-list, JWKS-based key lookup with in-memory caching, key-rotation refresh), and centralizes RFC 3986 path-segment encoding in HttpClient::encodePathSegments so that non-slash special characters like ? and # in interpolated IDs are encoded before they can open a new query or fragment.

  • lib/SessionManager.php: Rewrites decodeAccessToken to validate the JWS header alg against an RS256 allow-list, fetch/cache JWKS by clientId, match JWK by kid, build an RSA public key from the DER-encoded JWK, verify the signature with openssl_verify, and check expiration only after successful verification. Adds a manual RSA JWK → PEM converter to avoid a new dependency. Adds a 5-minute in-memory JWKS cache with forced-refresh on kid miss.
  • lib/HttpClient.php: Adds encodePathSegments/encodePathSegment to percent-encode unsafe non-slash characters in each URL path segment while preserving already-encoded %XX triplets, preventing a raw ? or # in an ID from opening a spurious query/fragment.
  • tests/: New tests cover valid signatures, tampered signatures, alg: none, unknown kid, JWKS caching, and key-rotation refresh; HttpClientTest pins both the double-encoding-free and raw-slash caveat behaviors.

Confidence Score: 5/5

Safe to merge; the JWS verification path is sound, the JWKS cache is correctly implemented, and the path-encoding change is well-tested with documented caveats.

The core security additions — RS256-only algorithm enforcement, per-kid JWKS lookup with forced-refresh on miss, manual DER/PEM construction, and openssl_verify integration — are all implemented correctly. The DER OID encoding is byte-accurate. All current throw sites inside decodeAccessToken extend Exception, so the catch block is complete for today's code paths. The only open items are defensive hardening suggestions that do not represent present defects.

No files require special attention; the three suggestions on lib/SessionManager.php are defensive hardening, not corrections to current broken behaviour.

Important Files Changed

Filename Overview
lib/SessionManager.php Rewrites decodeAccessToken with RS256-only JWS verification, JWKS caching, and manual RSA JWK-to-PEM DER construction; all suggestion comments are defensive hardening rather than corrections to broken behaviour.
lib/HttpClient.php Adds encodePathSegments/encodePathSegment to resolve non-slash special characters in path IDs before URL assembly; raw / inside an unencoded ID still acts as a separator (documented caveat), no double-encoding issues.
tests/SessionManagerTest.php Adds tests for valid RS256 token, tampered signature, alg:none rejection, unknown kid (with two queued responses to reach the intended guard), JWKS caching, and key-rotation refresh; all test scenarios are well-constructed.
tests/HttpClientTest.php Adds tests for no-double-encoding of pre-encoded IDs, ? encoding in raw segments, and the documented raw-slash caveat; behavioral contract is clearly pinned.

Reviews (5): Last reviewed commit: "chore(session,http): address Greptile re..." | Re-trigger Greptile

Comment thread lib/SessionManager.php
Comment on lines +392 to +397
// TODO(security-fix-plan.md, finding #60): enforce documented WorkOS
// `iss` and `aud` values once empirically confirmed. The other WorkOS
// SDKs (Ruby, Python) currently skip `aud` verification, so the
// canonical values are not authoritatively documented in this repo.
// Track resolution under "Open questions / follow-ups" in the plan.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security iss and aud claims are not validated

Team policy (rule e0f82177) requires that JWTs always have their iss and aud claims validated. The TODO defers this indefinitely, leaving the door open for tokens issued by a different issuer or intended for a different audience to be accepted. An attacker with a valid WorkOS-signed token for a different client application (different aud) or a different issuer would still pass signature verification and be treated as authenticated. The Ruby/Python SDK comparison in the comment is not sufficient justification when the org's own policy mandates validation.

Rule Used: JWTs should always be validated before use and the... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged on the rule, but I want to flag why this PR keeps the TODO rather than fixing it inline:

I checked the canonical workos-node SDK — its isValidJwt (in src/user-management/session.ts) calls jwtVerify(accessToken, jwks) with no issuer or audience options, so jose verifies signature + alg + exp only. There are zero iss/aud string literals anywhere in workos-node's source or tests. Combined with the Ruby/Python SDKs already skipping aud, none of the WorkOS SDKs currently validate these claims, and the canonical values aren't documented in any SDK repo.

If I hard-code defensible guesses (e.g. iss = baseUrl, aud = clientId) and the real tokens use something else (e.g. iss = https://api.workos.com/user_management/{clientId}), every production caller's session check breaks on upgrade. That's a worse outcome than the cross-tenant risk this rule targets — which already requires a WorkOS-signed token and a way to plant it in the victim's sealed cookie.

Plan: I'll get the canonical iss/aud values from the auth team and add validation in a follow-up PR. The TODO and rationale will be expanded to reference this conversation so the deferral is explicit.

Comment thread lib/SessionManager.php Outdated
gjtorikian added a commit to workos/workos-dotnet that referenced this pull request May 8, 2026
Defaulting ValidAudience to ClientId would reject every legitimate
token on upgrade if WorkOS access tokens carry a different `aud`
(e.g. a resource-server URL rather than the client identifier). The
workos-php investigation under workos/workos-php#386 confirmed that
the canonical WorkOS `iss` / `aud` values are not documented across
SDKs — workos-node validates only signature + algorithm + expiry,
and Ruby/Python skip `aud` entirely.

Audience now mirrors issuer: validated only when ValidAudience is
explicitly configured. Signature, algorithm, and lifetime remain
enforced unconditionally. Once the canonical claim values are
confirmed, defaults can be reintroduced safely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously every authenticate() call issued a live HTTP GET to the JWKS
endpoint, making each session check dependent on an external round-trip
and inflating latency. Add an in-memory cache on SessionManager keyed by
client ID with a 300-second TTL, plus a force-refresh path on `kid` miss
so newly-rotated signing keys are still discovered without waiting for
TTL expiry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread lib/HttpClient.php
Comment thread tests/SessionManagerTest.php
- fetchJwks: raise RuntimeException on null response so the `array`
  return contract is honest (was a latent TypeError under malformed
  upstream JWKS responses).
- decodeAccessToken: drop unused $baseUrl parameter; JWKS fetches go
  through $this->client and never read it.
- testAuthenticateRejectsUnknownKid: queue a second JWKS response so
  the forced kid-miss refresh actually exercises the
  "No JWKS key matches JWT kid" guard instead of passing on a
  MockHandler queue-exhaustion exception.
- encodePathSegments: correct the misleading comment — only non-slash
  unsafe characters are guarded; callers remain responsible for
  rawurlencode-ing IDs containing `/`. Strengthen the test to assert
  the exact `?`-encoded path and pin the documented `/`-pass-through
  caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gjtorikian gjtorikian merged commit aa64119 into main May 11, 2026
8 checks passed
@gjtorikian gjtorikian deleted the updates branch May 11, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants