Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
536cb9b
fix(session): guard nil/empty cookie_password in SessionManager seal …
gjtorikian May 7, 2026
a48a195
fix(session): require exp claim on access-token JWT decode
gjtorikian May 7, 2026
060ee35
fix(session): persist seal_data before decoding refreshed access token
gjtorikian May 7, 2026
072908a
fix(session): require cookie_password.bytesize >= 32 at every entry p…
gjtorikian May 7, 2026
4181faf
fix(base_client): redact bearer-token path segments before logging
gjtorikian May 7, 2026
5cff2d1
fix(actions): use symmetric tolerance check for verify_header timestamp
gjtorikian May 7, 2026
cc65ed7
fix(webhooks): use symmetric tolerance check for verify_header timestamp
gjtorikian May 7, 2026
a0fe564
fix(user_management): strip caller-supplied PKCE params in get_author…
gjtorikian May 7, 2026
6c2a75f
fix(review): drop dead redact_path entries and unreachable exp.nil? b…
gjtorikian May 8, 2026
9ce069f
revert(session): drop required_claims: ['exp'] format-tightening on J…
gjtorikian May 8, 2026
2c6ecd0
test(webhooks,actions): cover future-dated timestamp rejection
gjtorikian May 8, 2026
da1585f
fix(session): expose rotated cookie on RefreshError so durability rea…
gjtorikian May 12, 2026
a860c36
fix(session): raise ArgumentError for short cookie_password on refresh
gjtorikian May 12, 2026
2a89081
refactor(session_manager): privatize validate_cookie_password!
gjtorikian May 12, 2026
1875e11
fix(base_client): redact sensitive query-string values before logging
gjtorikian May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,25 @@ user = WorkOS.client.user_management.create_user(
puts user.id
```

### Sealed sessions (cookie_password requirements)

When you use `client.session_manager` to seal session cookies, the
`cookie_password` you supply must be **at least 32 bytes** of high-entropy
secret material (typically 32 random bytes encoded as base64 or a 64-char
hex string). The SDK derives the AES-256-GCM key from this password via
SHA-256, and a passphrase shorter than 32 bytes makes the resulting key
materially easier to brute-force offline.

Generate a suitable secret once and store it as an environment variable:

```sh
ruby -rsecurerandom -e 'puts SecureRandom.base64(32)'
```

Anything shorter than 32 bytes (including `nil` or `""`) raises
`ArgumentError` at SDK init time — sealing or unsealing will not silently
proceed with a weakened key.

### Verify a webhook

```ruby
Expand Down
21 changes: 21 additions & 0 deletions docs/V7_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,27 @@ Session management was one of the largest refactors in v7. The old `WorkOS::Sess

If your application seals session cookies, refreshes access tokens, or decodes the access-token JWT, every one of these call sites needs to be updated.

#### `cookie_password` minimum length (32 bytes)

v7 enforces a **minimum 32-byte length** on every `cookie_password` you supply
to the session manager (`load`, `seal_data`, `unseal_data`,
`seal_session_from_auth_response`, and the underlying `Encryptors::AesGcm`).

Anything shorter — including `nil` or `""` — now raises `ArgumentError` at the
moment the SDK is asked to seal or unseal. Older deployments that used a
short passphrase (e.g. a 16-character secret) will start erroring at app
boot or the next sealed-session request.

Pick a 32+ byte secret once and store it as an environment variable:

```sh
ruby -rsecurerandom -e 'puts SecureRandom.base64(32)'
```

The KDF itself (single-pass SHA-256) is unchanged in this release, so
existing sealed cookies continue to round-trip as long as the same
(now-length-validated) password is in use.

#### Sealing a cookie from an authentication response

In v6, you asked `authenticate_with_*` to seal the cookie for you:
Expand Down
2 changes: 1 addition & 1 deletion lib/workos/actions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ module Actions
def verify_header(payload:, sig_header:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS)
timestamp_ms, signature_hash = parse_signature_header(sig_header)
issued_at = timestamp_ms.to_i / 1000.0
if (Time.now.to_f - issued_at) > tolerance
if (Time.now.to_f - issued_at).abs > tolerance
raise WorkOS::SignatureVerificationError.new(
message: "Timestamp outside the tolerance zone",
http_status: nil
Expand Down
76 changes: 71 additions & 5 deletions lib/workos/base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,19 +134,20 @@ def execute_request(request:, request_options: nil)
attempt = 0

loop do
log(:debug, "request start", method: request.method, path: request.path, attempt: attempt + 1)
loggable_path = redact_path(request.path)
log(:debug, "request start", method: request.method, path: loggable_path, attempt: attempt + 1)
http = connection_for(base, timeout)
response = http.request(request)
return response if response.is_a?(Net::HTTPSuccess)

if attempt < retries && retryable?(response)
attempt += 1
inject_retry_idempotency_key(request)
log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, status: response.code.to_i)
log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, status: response.code.to_i)
sleep(retry_delay(response, attempt))
next
end
log(:warn, "request error", method: request.method, path: request.path, status: response.code.to_i, request_id: response["x-request-id"] || response["X-Request-Id"])
log(:warn, "request error", method: request.method, path: loggable_path, status: response.code.to_i, request_id: response["x-request-id"] || response["X-Request-Id"])
handle_error_response(response)
rescue Net::OpenTimeout, Net::ReadTimeout,
Errno::ECONNRESET, Errno::ECONNREFUSED,
Expand All @@ -155,11 +156,11 @@ def execute_request(request:, request_options: nil)
if attempt < retries
attempt += 1
inject_retry_idempotency_key(request)
log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, error: e.class.name)
log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, error: e.class.name)
sleep(retry_delay(nil, attempt))
next
end
log(:warn, "connection error", method: request.method, path: request.path, error: e.class.name, message: e.message)
log(:warn, "connection error", method: request.method, path: loggable_path, error: e.class.name, message: e.message)
raise WorkOS::APIConnectionError.new(message: e.message)
end
end
Expand All @@ -179,6 +180,71 @@ def shutdown

private

# Redact path segments that carry bearer-equivalent tokens (e.g.
# `/user_management/invitations/by_token/<token>`,
# `/user_management/magic_auth/<token>`, password-reset / email-
# verification token paths) before the path is written to a logger.
# The WorkOS API exposes a small number of "by_token" endpoints whose
# path segments are themselves authentication material; redacting them
# here means the SDK never emits the token in its own log/retry/error
# messages even when the host application configures verbose logging.
REDACTED_TOKEN_PREFIXES = %w[
/user_management/invitations/by_token
/user_management/magic_auth
/user_management/password_reset
/user_management/email_verification
].freeze
Comment thread
greptile-apps[bot] marked this conversation as resolved.
private_constant :REDACTED_TOKEN_PREFIXES

# Query-string keys whose values are bearer-equivalent or otherwise
# sensitive and should never appear in SDK log lines. Defense-in-depth
# for any path that flows through execute_request with a sensitive
# query parameter (most WorkOS-issued tokens are path segments or POST
# bodies, but a few flows — e.g. authorize/logout redirects — surface
# them in the query string).
REDACTED_QUERY_KEYS = %w[
token
code
code_challenge
code_verifier
session_id
refresh_token
access_token
].freeze
private_constant :REDACTED_QUERY_KEYS

def redact_path(path)
return path if path.nil? || path.empty?

# Strip query string for the prefix match; reattach (scrubbed) after.
path_only, query = path.split("?", 2)
REDACTED_TOKEN_PREFIXES.each do |prefix|
next unless path_only.start_with?("#{prefix}/")

# Replace every segment after the matched prefix with "[REDACTED]".
remainder = path_only[(prefix.length + 1)..]
next if remainder.nil? || remainder.empty?

redacted = remainder.split("/").map { "[REDACTED]" }.join("/")
path_only = "#{prefix}/#{redacted}"
break
end
query ? "#{path_only}?#{redact_query(query)}" : path_only
end

def redact_query(query)
return query if query.nil? || query.empty?

query.split("&").map { |pair|
key, value = pair.split("=", 2)
if value && !value.empty? && REDACTED_QUERY_KEYS.include?(key)
"#{key}=[REDACTED]"
else
pair
end
}.join("&")
end

def append_query(path, params)
return path unless params.is_a?(Hash) && !params.empty?

Expand Down
24 changes: 19 additions & 5 deletions lib/workos/encryptors/aes_gcm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ module WorkOS
module Encryptors
class AesGcm
SEAL_VERSION = 0x01
# Minimum cookie_password byte length. AES-256-GCM derives a 32-byte
# key from the password via SHA-256; a passphrase shorter than the
# output it derives to provides less than the full keyspace and makes
# offline brute-force feasible. See README + V7_MIGRATION_GUIDE.md.
MIN_KEY_BYTES = 32

def seal(data, key)
validate_key!(key)
json = data.is_a?(String) ? data : JSON.generate(data)
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
cipher.key = derive_key(key)
Expand All @@ -26,13 +32,16 @@ def seal(data, key)
end

def unseal(sealed, key)
validate_key!(key)
raw = Base64.decode64(sealed.to_s)
decode_v7(raw, key)
rescue ArgumentError, OpenSSL::Cipher::CipherError => original_error
begin
decode_old(raw, key)
rescue ArgumentError, OpenSSL::Cipher::CipherError
raise original_error
decode_v7(raw, key)
rescue ArgumentError, OpenSSL::Cipher::CipherError => original_error
begin
decode_old(raw, key)
rescue ArgumentError, OpenSSL::Cipher::CipherError
raise original_error
end
end
end

Expand Down Expand Up @@ -83,6 +92,11 @@ def parse_decoded(decoded)
def derive_key(passphrase)
Digest::SHA256.digest(passphrase.to_s)
end

def validate_key!(key)
raise ArgumentError, "cookie_password is required" if key.nil? || key.to_s.empty?
raise ArgumentError, "cookie_password must be at least #{MIN_KEY_BYTES} bytes" if key.to_s.bytesize < MIN_KEY_BYTES
end
end
end
end
35 changes: 28 additions & 7 deletions lib/workos/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ module WorkOS
# @example Build a logout URL
# url = session.get_logout_url(return_to: "https://app.example.com")
class Session
# Minimum cookie_password byte length. AES-256-GCM derives a 32-byte
# key from the password via SHA-256; a passphrase shorter than the
# output it derives to provides less than the full keyspace and makes
# offline brute-force feasible. Require callers to supply at least 32
# bytes of high-entropy secret. See README + V7_MIGRATION_GUIDE.md.
MIN_COOKIE_PASSWORD_BYTES = 32

def initialize(manager, seal_data:, cookie_password:)
raise ArgumentError, "cookie_password is required" if cookie_password.nil? || cookie_password.empty?
raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if cookie_password.bytesize < MIN_COOKIE_PASSWORD_BYTES
@manager = manager
@client = manager.client
@seal_data = seal_data
Expand Down Expand Up @@ -57,7 +65,7 @@ def authenticate(include_expired: false, &claim_extractor)
return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT)
end

is_expired = decoded["exp"] && decoded["exp"] < Time.now.to_i
is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i

SessionManager::AuthSuccess.new(
authenticated: !is_expired,
Expand All @@ -77,6 +85,11 @@ def authenticate(include_expired: false, &claim_extractor)

def refresh(organization_id: nil, cookie_password: nil)
effective_password = cookie_password || @cookie_password
# Validate up front so a caller-supplied short password raises ArgumentError
# (matching Session#initialize) instead of being swallowed by the
# unseal_data rescue and surfacing as INVALID_SESSION_COOKIE.
raise ArgumentError, "cookie_password is required" if effective_password.nil? || effective_password.empty?
raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if effective_password.bytesize < MIN_COOKIE_PASSWORD_BYTES

session = begin
@manager.unseal_data(@seal_data, effective_password)
Expand Down Expand Up @@ -105,17 +118,20 @@ def refresh(organization_id: nil, cookie_password: nil)
impersonator: auth_response["impersonator"]
)

# Decode before mutating session state so a malformed access_token
# doesn't leave the Session half-updated.
decoded = @manager.decode_jwt(auth_response["access_token"])

# Persist the new seal/password BEFORE decoding the JWT, so a transient
# JWKS fetch error (or any decode failure on the freshly-minted token)
# leaves the Session with a usable sealed cookie that the caller can
# re-#authenticate against, rather than half-updated state.
@seal_data = sealed
@cookie_password = effective_password

decoded = @manager.decode_jwt(auth_response["access_token"])

SessionManager::RefreshSuccess.new(
authenticated: true,
sealed_session: sealed,
session_id: decoded["sid"],
organization_id: decoded["org_id"],
organization_id: auth_response["organization_id"] || decoded["org_id"],
role: decoded["role"],
roles: decoded["roles"],
permissions: decoded["permissions"],
Expand All @@ -127,7 +143,12 @@ def refresh(organization_id: nil, cookie_password: nil)
rescue WorkOS::AuthenticationError, WorkOS::InvalidRequestError => e
SessionManager::RefreshError.new(authenticated: false, reason: e.message)
rescue JWT::DecodeError => e
SessionManager::RefreshError.new(authenticated: false, reason: e.message)
# The refresh token was already rotated server-side before decode failed,
# so @seal_data holds the freshly-minted cookie. Surface it on the error
# struct so the caller can write the rotated cookie back to the browser
# and recover on a subsequent #authenticate, rather than re-sending the
# now-revoked refresh token.
SessionManager::RefreshError.new(authenticated: false, reason: e.message, sealed_session: @seal_data)
end

# Build the WorkOS session-logout URL for the currently authenticated session.
Expand Down
25 changes: 24 additions & 1 deletion lib/workos/session_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def normalize_custom_claims(custom_claims)
:roles, :permissions, :entitlements, :user, :impersonator, :feature_flags,
keyword_init: true
)
RefreshError = Struct.new(:authenticated, :reason, keyword_init: true)
RefreshError = Struct.new(:authenticated, :reason, :sealed_session, keyword_init: true)

# Failure reason constants
NO_SESSION_COOKIE_PROVIDED = "no_session_cookie_provided"
Expand Down Expand Up @@ -150,12 +150,14 @@ def refresh(seal_data:, cookie_password:, organization_id: nil)
# H06 — Raw seal: encrypt arbitrary data with a key string.
# Delegates to the configured encryptor (default: AES-256-GCM).
def seal_data(data, key)
validate_cookie_password!(key)
@encryptor.seal(data, key)
end

# H06 — Raw unseal: returns parsed JSON (Hash) or raw string if not JSON.
# Delegates to the configured encryptor (default: AES-256-GCM).
def unseal_data(sealed, key)
validate_cookie_password!(key)
@encryptor.unseal(sealed, key)
end

Expand All @@ -164,11 +166,20 @@ def seal_session_from_auth_response(access_token:, refresh_token:, cookie_passwo
payload = {"access_token" => access_token, "refresh_token" => refresh_token}
payload["user"] = user if user
payload["impersonator"] = impersonator if impersonator
# Delegates to seal_data, which calls validate_cookie_password!; no need
# to validate here too.
seal_data(payload, cookie_password)
end

# Verify an access-token JWT against the WorkOS JWKS for this client.
# Used by Session#authenticate; exposed publicly for advanced cases.
#
# NOTE on iss/aud/required_claims: this method intentionally does not
# enforce iss, aud, or required_claims. workos-node's `jose` call and
# workos-php's `isset($exp) && $exp < time()` accept exp-less tokens, and
# cross-SDK parity is required for the planned coordinated hardening of
# these claims. See commit 9ce069f for the rationale behind dropping the
# required_claims: ['exp'] tightening that was considered here.
def decode_jwt(access_token, verify_expiration: true)
jwks = fetch_jwks
JWT.decode(
Expand All @@ -182,6 +193,18 @@ def decode_jwt(access_token, verify_expiration: true)
).first
end

private

# Validate a cookie_password is non-empty and at least the minimum
# byte length required by Session::MIN_COOKIE_PASSWORD_BYTES (32).
# Defense-in-depth — Session#initialize enforces the same invariant
# on the load path; this guards the inline #seal_data / #unseal_data
# entry points.
def validate_cookie_password!(key)
raise ArgumentError, "cookie_password is required" if key.nil? || key.empty?
raise ArgumentError, "cookie_password must be at least #{Session::MIN_COOKIE_PASSWORD_BYTES} bytes" if key.bytesize < Session::MIN_COOKIE_PASSWORD_BYTES
end

# Cached JWKS fetch (5-minute TTL, thread-safe).
def fetch_jwks(now: Time.now)
@jwks_mutex.synchronize do
Expand Down
7 changes: 7 additions & 0 deletions lib/workos/user_management.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,13 @@ def get_authorization_url(redirect_uri:, client_id: nil, provider: nil, connecti
def get_authorization_url_with_pkce(redirect_uri:, client_id: nil, **opts)
pair = WorkOS::PKCE.generate_pair
state = opts.delete(:state) || WorkOS::PKCE.generate_code_verifier
# Strip caller-supplied PKCE params: this helper exists specifically
# to generate them, so a caller-provided value would either silently
# override our freshly-generated challenge (defeating the helper) or
# collide with the keyword args below and raise. Mirror the existing
# opts.delete(:state) pattern.
opts.delete(:code_challenge)
opts.delete(:code_challenge_method)
url = get_authorization_url(
redirect_uri: redirect_uri,
client_id: client_id,
Expand Down
2 changes: 1 addition & 1 deletion lib/workos/webhooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def verify_header(payload:, sig_header:, secret:, tolerance: DEFAULT_TOLERANCE_S
timestamp_ms, signature_hash = parse_signature_header(sig_header)
max_age = tolerance.to_i
issued_at = timestamp_ms.to_i / 1000.0
if (Time.now.to_f - issued_at) > max_age
if (Time.now.to_f - issued_at).abs > max_age
raise WorkOS::SignatureVerificationError.new(
message: "Timestamp outside the tolerance zone",
http_status: nil
Expand Down
Loading