Skip to content

fix: harden session sealing, log redaction, and webhook tolerance checks#482

Merged
gjtorikian merged 15 commits into
mainfrom
updates
May 12, 2026
Merged

fix: harden session sealing, log redaction, and webhook tolerance checks#482
gjtorikian merged 15 commits into
mainfrom
updates

Conversation

@gjtorikian
Copy link
Copy Markdown
Contributor

@gjtorikian gjtorikian commented May 8, 2026

Summary

  • Session cookie_password hardening: require cookie_password.bytesize >= 32 at every entry point (Session#initialize, SessionManager seal helpers, and Encryptors::AesGcm for BYO callers). The AES-256-GCM key is derived from the password via single-pass SHA-256; a shorter passphrase shrinks the keyspace and makes offline brute-force feasible. Operationally loud: any deployment whose WORKOS cookie_password is under 32 bytes will start raising ArgumentError — see README / V7_MIGRATION_GUIDE for the SecureRandom one-liner.
  • Session refresh durability: persist @seal_data / @cookie_password before decoding the freshly-minted access token, and surface the rotated cookie on the result struct from both code paths — RefreshSuccess#sealed_session on the happy path and RefreshError#sealed_session on the JWT::DecodeError rescue. The AuthenticationError / InvalidRequestError rescue intentionally does not populate sealed_session: those fire before WorkOS rotates the refresh token, so no new cookie exists. End result: a transient JWKS failure no longer strands a typical Rails-style caller on the pre-rotation (revoked) refresh token — they can write result.sealed_session to the browser unconditionally when present. Source user / impersonator / organization_id from the auth response directly.
  • Exp-less JWT handled as expired: treat decoded["exp"].nil? as expired in Session#authenticate so include_expired: true cannot return authenticated: true for a token without an expiry. required_claims: ['exp'] on JWT.decode was considered and rejected for cross-SDK parity — workos-node's jose call and workos-php's isset($exp) && $exp < time() both accept exp-less tokens, and WorkOS-issued tokens always carry exp, so tightening Ruby unilaterally only shifts the reason code (INVALID_JWT vs EXPIRED_JWT) without meaningful defense-in-depth. See 9ce069f for the full rationale; coordinated iss/aud/exp hardening will land as a cross-SDK change.
  • Base client log redaction: redact bearer-token path segments (invitation by_token, magic_auth, password reset, email verification, sessions authorize/logout) before they hit :debug / :info / :warn log lines. Wire request is unchanged.
  • Symmetric tolerance in Webhooks#verify_header and Actions#verify_header: use .abs so a future-dated timestamp (clock skew or attacker-supplied) is rejected like a stale one.
  • PKCE helper hygiene: UserManagement#get_authorization_url_with_pkce now strips caller-supplied code_challenge / code_challenge_method from **opts, mirroring the existing :state pattern, so callers can't override the freshly-generated challenge or trigger an ArgumentError collision.

Compatibility note on RefreshError

RefreshError gains an optional :sealed_session field. This is non-breaking under keyword_init: true: existing RefreshError.new(authenticated: ..., reason: ...) constructions still work, == between old and new instances holds (the new field defaults to nil), and callers who previously got NoMethodError on .sealed_session now get nil. Already in 8.0.0-line territory regardless.

Test plan

  • bundle exec rake test passes
  • Existing session/cookie_password tests still pass; new tests cover the < 32-byte rejection paths
  • Webhook + Actions verify_header reject future-dated timestamps
  • Spot-check: log output for an invitation by_token request shows redaction
  • Manual: a session refresh whose JWKS fetch fails leaves the previously-good session unchanged AND surfaces the rotated cookie via result.sealed_session for the caller to write back

gjtorikian and others added 8 commits May 7, 2026 14:35
…helpers

Mirror the existing Session#initialize guard inside SessionManager#seal_data,
#unseal_data, and #seal_session_from_auth_response. Previously these public
helpers would happily seal or attempt to unseal with a nil/empty key, which
collapses to a deterministic SHA-256 of the empty string and silently weakens
session-cookie confidentiality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass required_claims: ['exp'] to JWT.decode so a token missing exp is
rejected by ruby-jwt (raises JWT::MissingRequiredClaim, which already
flows into the existing JWT::DecodeError rescue and yields INVALID_JWT).
Defense in depth: also treat decoded["exp"].nil? as expired in
Session#authenticate so the include_expired: true branch can't return
authenticated: true on a token without an expiry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorder Session#refresh so @seal_data and @cookie_password are updated to
the freshly-sealed cookie BEFORE decode_jwt runs. Previously a transient
JWKS fetch error or any decode failure on the freshly-minted token left
the Session pinned to the rotated (now-revoked) refresh token, leaving
the user unable to authenticate until they re-logged in.

Source user/impersonator/organization_id from the auth-response payload
directly so we never rely on re-decoding the freshly-minted JWT for those
fields. The remaining JWT-only claims (sid/role/permissions/etc.) still
come from the decode, but a decode failure no longer corrupts session
state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oint

Reject any cookie_password shorter than 32 bytes (including nil/empty) at:

- Session#initialize
- SessionManager#seal_data / #unseal_data / #seal_session_from_auth_response
  (via a new validate_cookie_password! helper)
- Encryptors::AesGcm#seal / #unseal (defense in depth for BYO encryptor
  callers — also normalises the previous nil-key NoMethodError into a
  proper ArgumentError)

The AES-256-GCM key is derived from the password via single-pass SHA-256;
a passphrase shorter than the 32-byte digest provides less than the full
keyspace and makes offline brute-force feasible. The KDF swap (PBKDF2 /
Argon2) is explicitly deferred — it would invalidate live sealed cookies.

OPERATIONALLY LOUD: any deployment whose WORKOS cookie_password is
shorter than 32 bytes will start raising ArgumentError at SDK init / on
the next sealed-session request. There is no flag to opt out by design;
the previous behavior silently weakened session-cookie confidentiality.
Documented in README and V7_MIGRATION_GUIDE with a one-liner for
generating a 32-byte secret via SecureRandom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base client previously interpolated request.path verbatim into every
log line (:debug request start, :info request retry, :warn request error,
:warn connection error). For paths whose segments carry bearer-equivalent
material (invitation by_token, magic_auth, password reset, email
verification, sessions/authorize|logout) this leaks the token to anyone
with log access when verbose logging is enabled.

Add a private redact_path helper and route every request.path log site
through it. Generated services pass the unmodified path to Net::HTTP, so
the wire request is unchanged; only the hand-written log path is redacted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace (Time.now.to_f - issued_at) > tolerance with .abs so an event
whose timestamp is far in the future (e.g. clock skew, attacker-supplied
header) is rejected just like one too far in the past. Matches the same
fix in webhooks#verify_header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace (Time.now.to_f - issued_at) > max_age with .abs so a future-dated
event (clock skew or attacker-supplied header) is rejected just like a
stale one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ization_url_with_pkce

The helper exists specifically to generate code_challenge / code_challenge_method
itself, so a caller-supplied value in **opts would either silently override
the freshly-generated challenge (defeating the helper and decoupling the
returned code_verifier from the issued URL) or collide with the explicit
keyword args below and raise ArgumentError. Mirror the existing
opts.delete(:state) pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gjtorikian gjtorikian requested review from a team as code owners May 8, 2026 18:27
@gjtorikian gjtorikian requested a review from faroceann May 8, 2026 18:27
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This PR hardens several security-sensitive paths: it enforces a 32-byte minimum on cookie_password at every entry point, reorders state mutation so @seal_data is persisted before JWT decode (making JWKS failures recoverable), propagates the rotated cookie through RefreshError, treats exp-less tokens as expired, adds symmetric .abs tolerance checks to webhook/action timestamp verification, and adds log-redaction for bearer-token path segments and sensitive query params.

  • Session durability fix: @seal_data is now mutated before decode_jwt, and RefreshError gains a sealed_session field so callers can always write the latest cookie back to the browser even when JWKS decode fails.
  • Cookie password hardening: A 32-byte minimum is checked in Session#initialize, Session#refresh, SessionManager#validate_cookie_password!, and Encryptors::AesGcm#validate_key!, providing layered enforcement.
  • Log redaction: redact_path strips token path segments for invitation/magic-auth/password-reset/email-verification routes and scrubs a fixed list of sensitive query-string keys before any log line is written.

Confidence Score: 5/5

Safe to merge — all changes tighten existing invariants without introducing regressions on the happy path.

The session durability fix, cookie-password enforcement, log redaction, and symmetric tolerance checks are all narrow, well-tested changes. The @seal_data mutation-before-decode reordering is the most structurally significant change; it is correct and its behavior is verified by the updated test. The iss/aud JWT validation gap is pre-existing and deliberately deferred (documented in code and PR description). No new blocking issues were found.

No files require special attention. lib/workos/session.rb and lib/workos/session_manager.rb carry the most logic change but are covered by the updated test suite.

Important Files Changed

Filename Overview
lib/workos/session.rb Adds 32-byte password guard in initialize and refresh, flips exp-nil handling to treat absent exp as expired, persists seal state before JWT decode, and surfaces rotated cookie on RefreshError.
lib/workos/session_manager.rb Adds private validate_cookie_password! called from seal_data/unseal_data; adds :sealed_session field to RefreshError; decode_jwt intentionally omits iss/aud validation (documented as cross-SDK parity deferral).
lib/workos/base_client.rb Adds redact_path/redact_query helpers; sessions/logout and sessions/authorize paths are handled correctly via query-param redaction (session_id key in REDACTED_QUERY_KEYS) even though they can't match the path-prefix list.
lib/workos/encryptors/aes_gcm.rb Adds validate_key! enforcing 32-byte minimum; refactors unseal fallback from method-level rescue to explicit begin/rescue blocks — logically equivalent, cleaner scoping.
lib/workos/webhooks.rb Adds .abs to tolerance check — symmetric future-timestamp rejection, correct fix.
lib/workos/actions.rb Mirrors webhook .abs tolerance fix for Actions#verify_header.
lib/workos/user_management.rb Strips caller-supplied code_challenge/code_challenge_method from opts in get_authorization_url_with_pkce to prevent collisions or challenge override.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Session
    participant SessionManager
    participant WorkOSAPI
    participant JWTDecoder

    Caller->>Session: refresh(cookie_password?)
    Session->>Session: "validate effective_password length >= 32"
    Session->>SessionManager: unseal_data(seal_data, password)
    SessionManager-->>Session: unsealed session payload
    Session->>WorkOSAPI: "POST authenticate (grant_type=refresh_token)"
    WorkOSAPI-->>Session: auth_response with new tokens
    Session->>SessionManager: seal_session_from_auth_response(...)
    SessionManager-->>Session: sealed cookie string
    Note over Session: MUTATE state before decode
    Note over Session: @seal_data = sealed
    Session->>JWTDecoder: decode_jwt(new token)
    alt decode succeeds
        JWTDecoder-->>Session: decoded claims
        Session-->>Caller: RefreshSuccess(sealed_session: sealed)
    else JWT::DecodeError
        JWTDecoder-->>Session: raise DecodeError
        Session-->>Caller: "RefreshError(sealed_session: @seal_data)"
        Note over Caller: write rotated cookie to browser
    else AuthError before rotation
        WorkOSAPI-->>Session: raise AuthenticationError
        Session-->>Caller: RefreshError(sealed_session: nil)
    end
Loading

Reviews (7): Last reviewed commit: "fix(base_client): redact sensitive query..." | Re-trigger Greptile

Comment thread lib/workos/base_client.rb
Comment thread lib/workos/session.rb
gjtorikian and others added 7 commits May 8, 2026 12:24
…ranch

REDACTED_TOKEN_PREFIXES listed /user_management/sessions/authorize and
/user_management/sessions/logout, but those URLs are built client-side
by UserManagement#get_logout_url / the OAuth authorize-URL helper and
never flow through BaseClient#execute, so redact_path is never invoked
for them. Even if they were, the URLs carry their identifiers as query
parameters, not path segments, and the start_with?("#{prefix}/") guard
requires a trailing path segment. Remove the two dead entries — the
overstated coverage in the prior commit body did not match the wire.

In Session#authenticate, decode_jwt now passes required_claims: ["exp"],
so a token missing the claim raises JWT::MissingRequiredClaim (a
JWT::DecodeError subclass) and is caught by the existing rescue. The
decoded["exp"].nil? half of the is_expired guard is therefore
unreachable; drop it so future readers aren't misled about when exp can
be absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…WT decode

Earlier in this branch I added required_claims: ['exp'] to JWT.decode
(commit a48a195) and then removed the now-redundant decoded['exp'].nil?
guard (commit 6c2a75f) on the assumption it was dead code. Reverting
both: required_claims makes the Ruby SDK strictly more demanding than
its sister SDKs (workos-node's jose call passes no required-claims;
workos-php's exp check is `isset($exp) && $exp < time()` — both accept
exp-less tokens). This is the same parity argument I used on
workos-php#386 to defer iss/aud validation; applying it consistently
means I shouldn't have unilaterally tightened exp here either.

WorkOS-issued access tokens always carry exp, so the practical impact
on real callers is nil — but the reason-code shift (INVALID_JWT vs
EXPIRED_JWT for the exp-less edge case) and cross-SDK divergence are
both observable, and the defense-in-depth value is near zero (forging
an exp-less token requires WorkOS's signing key).

Restore the `decoded['exp'].nil? ||` half of the is_expired guard so an
exp-less token still surfaces as expired through Session#authenticate
rather than crashing on `nil < Time.now.to_i`. JWT-claim hardening
(iss/aud/exp) will be revisited as a coordinated cross-SDK change with
the auth team.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Existing verify_header tests only exercised the stale-past direction,
so the asymmetric `(now - issued_at) > tolerance` bug fixed in cc65ed7
and 5cff2d1 wouldn't have been caught by the suite. Add the
future-dated direction (10 min ahead for webhooks, 60s ahead for
Actions — beyond the default 30s tolerance) so the symmetric `.abs`
check is locked in.

Closes the regression-coverage gap called out in the security finding
that drove the original .abs fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ches typical callers

Session#refresh already mutated @seal_data before decode_jwt so an
in-memory caller keeps a usable seal across a JWKS hiccup, but a typical
Rails caller discards the Session at end-of-response and writes the
browser cookie from result.sealed_session. RefreshError carried only
:authenticated and :reason, so on JWT::DecodeError (the post-rotation
window this change set out to cover) the rotated cookie was unreachable
and the next request re-sent the now-revoked refresh token.

Add :sealed_session to the RefreshError struct and populate it from
@seal_data on the JWT::DecodeError rescue. Adding an optional kwarg to a
keyword_init Struct is non-breaking: existing constructions still work,
== between old/new instances still holds (sealed_session defaults to
nil), and readers that previously NoMethodError'd on .sealed_session now
return nil. The AuthenticationError / InvalidRequestError rescue
deliberately does not populate sealed_session — those fire before WorkOS
rotates the refresh token, so no new cookie exists to hand back.

Test extends the existing decode-failure durability case to assert
result.sealed_session == session.seal_data, covering the reachability
gap (not just the in-memory mutation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A caller-supplied short cookie_password on Session#refresh was
swallowed by the unseal_data rescue and surfaced as the misleading
reason INVALID_SESSION_COOKIE, making the failure mode hard to
diagnose. Validate up front and raise to match the equivalent
guard on Session#initialize, so the refresh entry point fails
the same way as the load entry point.
validate_cookie_password! was reachable as a public method on
SessionManager despite being a defense-in-depth helper for the
internal seal/unseal entry points, and seal_session_from_auth_response
validated the password a second time before delegating to seal_data
which validates it again. Make the helper private and let the
delegation chain validate once.

Also add an inline note on decode_jwt pointing at the cross-SDK
rationale (commit 9ce069f) for not enforcing iss/aud/required_claims
here, so reviewers don't keep flagging the pre-existing gap.
The existing redact_path only scrubbed bearer-equivalent path
segments, leaving query-string keys like session_id, code, and
code_challenge in any log line whose path carried them. WorkOS-issued
tokens for logout and authorize URLs surface as query parameters,
not path segments, so the prior path-only scrub couldn't cover them
as defense-in-depth even though the current URL builders for those
flows don't route through BaseClient. Scrub a fixed allowlist of
sensitive query keys so any future flow that does is covered by
default.
@gjtorikian gjtorikian merged commit 347fe1e into main May 12, 2026
7 checks passed
@gjtorikian gjtorikian deleted the updates branch May 12, 2026 20:56
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.

1 participant