diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7254fa..f6af5d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+## [#.#.#] - YYYY-MM-DD
+
+### Added
+
+- Support for Cross-Tab Session (CTS) extraction from `pxcts` cookie
+- Support for JWT-based User Identifiers extraction from cookies or headers
+
+### Fixed
+
+- Decoded cookie (`px_cookie`) now included in risk API request for sensitive route calls
+- Decoded cookie (`px_cookie`) now included in block activities when a valid cookie exists
+
## [4.0.1] - 2025-11-16
### Changed
diff --git a/README.md b/README.md
index 430fea0..ca5d048 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@
* [Data-Enrichment](#data-enrichment)
* [Enrich Custom Params](#enrich-custom-params)
* [Login Credentials Extraction](#login-credentials-extraction)
+* [JWT User Identifiers](#jwt-user-identifiers)
* [Additional S2S Activity](#additional-s2s-activity)
* [Logging](#logging)
* [Module Mode](#module-mode)
@@ -641,6 +642,45 @@ function extractCreds() {
}
```
+### JWT User Identifiers
+
+This feature extracts user identity information from a JWT (JSON Web Token) present in a cookie or header, and sends it to PerimeterX as part of the risk evaluation and activity data.
+
+The extracted data includes:
+- **App User ID** - A single field representing the user's identity
+- **Additional Fields** - Optional extra fields from the JWT payload
+
+The enforcer also automatically extracts the Cross-Tab Session (CTS) value from the `pxcts` cookie if present.
+
+**Default:**
+
+All JWT configuration options are disabled by default (set to `null` or empty array).
+
+```php
+$perimeterxConfig = [
+ // Extract JWT from a cookie
+ 'px_jwt_cookie_name' => 'auth_token',
+ 'px_jwt_cookie_user_id_field_name' => 'sub',
+ 'px_jwt_cookie_additional_field_names' => ['session.id', 'account.type'],
+
+ // Or extract JWT from a header (cookie takes precedence if both configured)
+ 'px_jwt_header_name' => 'Authorization',
+ 'px_jwt_header_user_id_field_name' => 'user.id',
+ 'px_jwt_header_additional_field_names' => ['tenant.id']
+];
+```
+
+#### Configuration Options
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `px_jwt_cookie_name` | string | Name of the cookie containing the JWT |
+| `px_jwt_cookie_user_id_field_name` | string | Dot-notated path to the user ID field in the JWT payload |
+| `px_jwt_cookie_additional_field_names` | array | List of dot-notated paths for additional fields to extract |
+| `px_jwt_header_name` | string | Name of the header containing the JWT |
+| `px_jwt_header_user_id_field_name` | string | Dot-notated path to the user ID field in the JWT payload |
+| `px_jwt_header_additional_field_names` | array | List of dot-notated paths for additional fields to extract |
+
### Additional S2S Activity
To enhance detection on login credentials extraction endpoints, the following additional information is sent to PerimeterX
diff --git a/examples/test-adapter.php b/examples/test-adapter.php
new file mode 100644
index 0000000..2413cb8
--- /dev/null
+++ b/examples/test-adapter.php
@@ -0,0 +1,115 @@
+ PHP SDK names
+// null = keep the key as-is (SDK expects it with px_ prefix)
+$CONFIG_MAP = [
+ // Keys that need translation (spec tests use different names than SDK)
+ 'px_app_id' => 'app_id',
+ 'px_auth_token' => 'auth_token',
+ 'px_cookie_secret' => 'cookie_key',
+ 'px_module_enabled' => 'module_enabled',
+ 'px_blocking_score' => 'blocking_score',
+ 'px_module_mode' => 'module_mode',
+ 'px_backend_url' => 'perimeterx_server_host',
+ 'px_s2s_timeout' => 'api_timeout',
+ 'px_api_timeout' => 'api_timeout',
+ 'px_sensitive_routes' => 'sensitive_routes',
+ 'px_sensitive_headers' => 'sensitive_headers',
+ 'px_ip_headers' => 'ip_headers',
+ // Keys that keep px_ prefix (SDK expects it as-is)
+ 'px_first_party_enabled' => null,
+ 'px_cd_first_party_enabled' => null,
+ 'px_jwt_cookie_name' => null,
+ 'px_jwt_cookie_user_id_field_name' => null,
+ 'px_jwt_cookie_additional_field_names' => null,
+ 'px_jwt_header_name' => null,
+ 'px_jwt_header_user_id_field_name' => null,
+ 'px_jwt_header_additional_field_names' => null,
+ 'px_login_credentials_extraction_enabled' => null,
+ 'px_login_credentials_extraction' => null,
+ 'px_compromised_credentials_header' => null,
+ 'px_credentials_intelligence_version' => null,
+ 'px_additional_s2s_activity_header_enabled' => null,
+ 'px_automatic_additional_s2s_activity_enabled' => null,
+ 'px_send_raw_username_on_additional_s2s_activity' => null,
+ 'px_login_successful_reporting_method' => null,
+ 'px_login_successful_status' => null,
+ 'px_login_successful_header_name' => null,
+ 'px_login_successful_header_value' => null,
+];
+
+/**
+ * Translate spec test config (px_* names) to PHP SDK config names
+ */
+function translateConfig(array $config, array $configMap): array {
+ $translated = [];
+
+ foreach ($config as $key => $value) {
+ if (array_key_exists($key, $configMap)) {
+ $mappedKey = $configMap[$key];
+ $translated[$mappedKey ?? $key] = $value;
+ } elseif (strpos($key, 'px_') === 0) {
+ // Unmapped px_* keys: strip prefix
+ $translated[substr($key, 3)] = $value;
+ } else {
+ $translated[$key] = $value;
+ }
+ }
+
+ // Translate module_mode string to SDK constant
+ if (isset($translated['module_mode']) && is_string($translated['module_mode'])) {
+ $mode = strtolower($translated['module_mode']);
+ if ($mode === 'monitor' || $mode === 'monitoring') {
+ $translated['module_mode'] = Perimeterx::$MONITOR_MODE;
+ } elseif ($mode === 'active_blocking' || $mode === 'blocking' || $mode === 'active') {
+ $translated['module_mode'] = Perimeterx::$ACTIVE_MODE;
+ }
+ }
+
+ return $translated;
+}
+
+// Load config from JSON file
+$configFile = __DIR__ . '/enforcer_config.json';
+if (!file_exists($configFile)) {
+ http_response_code(200);
+ echo 'OK';
+ exit;
+}
+
+$fileConfig = json_decode(file_get_contents($configFile), true);
+if ($fileConfig === null) {
+ http_response_code(200);
+ echo 'OK';
+ exit;
+}
+
+$config = translateConfig($fileConfig, $CONFIG_MAP);
+
+// Validate required config
+if (empty($config['app_id']) || empty($config['cookie_key']) || empty($config['auth_token'])) {
+ http_response_code(200);
+ echo 'OK';
+ exit;
+}
+
+// Run the enforcer
+try {
+ $px = Perimeterx::Instance($config);
+ $px->pxVerify();
+} catch (Exception $e) {
+ http_response_code(200);
+ echo 'OK';
+}
diff --git a/px_metadata.json b/px_metadata.json
index eb63c38..2bc13e9 100644
--- a/px_metadata.json
+++ b/px_metadata.json
@@ -26,6 +26,7 @@
"risk_api",
"sensitive_headers",
"sensitive_routes",
- "vid_extraction"
+ "vid_extraction",
+ "user_identifiers"
]
}
\ No newline at end of file
diff --git a/src/Perimeterx.php b/src/Perimeterx.php
index 2866906..f88a627 100644
--- a/src/Perimeterx.php
+++ b/src/Perimeterx.php
@@ -32,6 +32,7 @@
use Perimeterx\CredentialsIntelligence\LoginSuccess\LoginSuccessfulReportingMethod;
use Perimeterx\CredentialsIntelligence\LoginSuccess\LoginSuccessfulParserFactory;
use Perimeterx\Utils\GuzzleHttpClient;
+use Perimeterx\UserIdentifiers\JwtExtractor;
final class Perimeterx
{
@@ -128,7 +129,13 @@ private function __construct(array $pxConfig = [])
'px_login_successful_reporting_method' => LoginSuccessfulReportingMethod::STATUS,
'px_login_successful_status' => 200,
'px_login_successful_header_name' => 'x-px-login-successful',
- 'px_login_successful_header_value' => '1'
+ 'px_login_successful_header_value' => '1',
+ 'px_jwt_cookie_name' => null,
+ 'px_jwt_cookie_user_id_field_name' => null,
+ 'px_jwt_cookie_additional_field_names' => [],
+ 'px_jwt_header_name' => null,
+ 'px_jwt_header_user_id_field_name' => null,
+ 'px_jwt_header_additional_field_names' => []
], $pxConfig);
if (empty($this->pxConfig['logger'])) {
@@ -163,7 +170,7 @@ public function pxVerify()
$pxCtx = new PerimeterxContext($this->pxConfig, $additionalFields);
$this->pxConfig['logger']->debug('Request context created successfully');
-
+ $this->enrichContextWithJwtData($pxCtx);
$validator = new PerimeterxCookieValidator($pxCtx, $this->pxConfig);
$cookie_valid = $validator->verify();
@@ -479,6 +486,39 @@ private function createAdditionalFields() {
return $additionalFields;
}
+ private function enrichContextWithJwtData($pxCtx) {
+ if (empty($this->pxConfig['px_jwt_cookie_name']) && empty($this->pxConfig['px_jwt_header_name'])) {
+ return;
+ }
+
+ try {
+ $jwtExtractor = new JwtExtractor($this->pxConfig);
+ $cookies = [];
+ if (isset($_SERVER['HTTP_COOKIE'])) {
+ foreach (explode('; ', $_SERVER['HTTP_COOKIE']) as $rawcookie) {
+ if (!empty($rawcookie) && strpos($rawcookie, '=') !== false) {
+ list($k, $v) = explode('=', $rawcookie, 2);
+ $cookies[$k] = $v;
+ }
+ }
+ }
+
+ $headers = $pxCtx->getHeaders();
+ $jwtData = $jwtExtractor->extract($cookies, $headers);
+
+ if (isset($jwtData)) {
+ if (isset($jwtData['app_user_id'])) {
+ $pxCtx->setAppUserId($jwtData['app_user_id']);
+ }
+ if (isset($jwtData['jwt_additional_fields'])) {
+ $pxCtx->setJwtAdditionalFields($jwtData['jwt_additional_fields']);
+ }
+ }
+ } catch (\Exception $e) {
+ $this->pxConfig['logger']->debug('Unable to extract JWT user identifiers: ' . $e->getMessage());
+ }
+ }
+
private function extractGraphqlFields() {
try {
$this->pxConfig['logger']->debug("GraphQL endpoint identified");
diff --git a/src/PerimeterxActivitiesClient.php b/src/PerimeterxActivitiesClient.php
index 62dfa37..91893eb 100644
--- a/src/PerimeterxActivitiesClient.php
+++ b/src/PerimeterxActivitiesClient.php
@@ -77,6 +77,14 @@ public function sendBlockActivity($pxCtx)
$details['block_action'] = $pxCtx->getResponseBlockAction();
$details['simulated_block'] = $this->pxConfig['module_mode'] == Perimeterx::$MONITOR_MODE;
+ if ($pxCtx->getDecodedCookie()) {
+ $details['px_cookie'] = $pxCtx->getDecodedCookie();
+ }
+
+ if ($pxCtx->getCookieHmac()) {
+ $details['px_cookie_hmac'] = $pxCtx->getCookieHmac();
+ }
+
$this->prepareActivitiesRequest("block", $pxCtx, $details);
}
@@ -132,6 +140,21 @@ private function addAdditionalFieldsToDetails(&$pxCtx, &$details) {
$details['graphql_operation_type'] = $graphqlFields->getOperationType();
$details['graphql_operation_name'] = $graphqlFields->getOperationName();
}
+
+ $crossTabSession = $pxCtx->getCrossTabSession();
+ if (isset($crossTabSession)) {
+ $details['cross_tab_session'] = $crossTabSession;
+ }
+
+ $appUserId = $pxCtx->getAppUserId();
+ if (isset($appUserId)) {
+ $details['app_user_id'] = $appUserId;
+ }
+
+ $jwtAdditionalFields = $pxCtx->getJwtAdditionalFields();
+ if (isset($jwtAdditionalFields) && !empty($jwtAdditionalFields)) {
+ $details['jwt_additional_fields'] = $jwtAdditionalFields;
+ }
}
public function generateActivity($activityType, $pxCtx, $details) {
diff --git a/src/PerimeterxContext.php b/src/PerimeterxContext.php
index caf2075..981be38 100644
--- a/src/PerimeterxContext.php
+++ b/src/PerimeterxContext.php
@@ -54,8 +54,11 @@ public function __construct($pxConfig, $additionalFields = null)
}
$this->http_method = $_SERVER['REQUEST_METHOD'];
$this->sensitive_route = $this->checkSensitiveRoutePrefix($pxConfig['sensitive_routes'], $this->uri);
- $this->loginCredentials = array_key_exists('loginCredentials', $additionalFields) ? $additionalFields['loginCredentials'] : null;
- $this->graphqlFields = array_key_exists('graphqlFields', $additionalFields) ? $additionalFields['graphqlFields'] : null;
+ if (is_array($additionalFields)) {
+ $this->loginCredentials = array_key_exists('loginCredentials', $additionalFields) ? $additionalFields['loginCredentials'] : null;
+ $this->graphqlFields = array_key_exists('graphqlFields', $additionalFields) ? $additionalFields['graphqlFields'] : null;
+ }
+
$this->requestId = PerimeterxUtils::createUuidV4();
}
@@ -275,6 +278,21 @@ private function extractIP($pxConfig, $headers)
* @var GraphqlFields
*/
protected $graphqlFields;
+
+ /**
+ * @var string
+ */
+ protected $cross_tab_session;
+
+ /**
+ * @var string
+ */
+ protected $app_user_id;
+
+ /**
+ * @var array
+ */
+ protected $jwt_additional_fields;
/**
* @return string
@@ -605,6 +623,9 @@ private function explodeCookieToVersion($delimiter, $cookie)
if ($k == '_pxhd' || $k == '_pxvid') {
$this->px_cookies[$k] = $v;
}
+ if ($k == 'pxcts') {
+ $this->cross_tab_session = $v;
+ }
array_push($this->request_cookie_names, $k);
} else {
@@ -854,4 +875,39 @@ public function getRequestId() {
public function areCredentialsCompromised() {
return isset($this->pxde) && isset($this->pxde->breached_account) && $this->pxde->breached_account;
}
+
+ /**
+ * @return string|null
+ */
+ public function getCrossTabSession() {
+ return $this->cross_tab_session;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getAppUserId() {
+ return $this->app_user_id;
+ }
+
+ /**
+ * @param string $app_user_id
+ */
+ public function setAppUserId($app_user_id) {
+ $this->app_user_id = $app_user_id;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getJwtAdditionalFields() {
+ return $this->jwt_additional_fields;
+ }
+
+ /**
+ * @param array $jwt_additional_fields
+ */
+ public function setJwtAdditionalFields($jwt_additional_fields) {
+ $this->jwt_additional_fields = $jwt_additional_fields;
+ }
}
diff --git a/src/PerimeterxLogger.php b/src/PerimeterxLogger.php
index 7916fcb..a282b4b 100644
--- a/src/PerimeterxLogger.php
+++ b/src/PerimeterxLogger.php
@@ -23,7 +23,7 @@ public function __construct($pxConfig) {
* Logs with an arbitrary level.
*
* @param mixed $level
- * @param string $message
+ * @param string|\Stringable $message
* @param array $context
*
* @return void
diff --git a/src/PerimeterxRiskClient.php b/src/PerimeterxRiskClient.php
index d8bfc5d..ea2055e 100644
--- a/src/PerimeterxRiskClient.php
+++ b/src/PerimeterxRiskClient.php
@@ -52,7 +52,7 @@ protected function formatHeaders()
}
/**
- * @return long current time in milliseconds
+ * @return float current time in milliseconds
*/
protected function getTimeInMilliseconds(){
return round(microtime(true) * 1000);
diff --git a/src/PerimeterxS2SValidator.php b/src/PerimeterxS2SValidator.php
index bfe0d2c..ee9594b 100644
--- a/src/PerimeterxS2SValidator.php
+++ b/src/PerimeterxS2SValidator.php
@@ -99,7 +99,7 @@ private function prepareRiskRequestBody() {
$requestBody['additional']['px_cookie_orig'] = $this->pxCtx->getPxCookie();
}
- if (in_array($this->pxCtx->getS2SCallReason(), ['cookie_expired', 'cookie_validation_failed'])) {
+ if (in_array($this->pxCtx->getS2SCallReason(), ['cookie_expired', 'cookie_validation_failed', 'sensitive_route'])) {
if ($this->pxCtx->getDecodedCookie()) {
$requestBody['additional']['px_cookie'] = $this->pxCtx->getDecodedCookie();
}
@@ -145,6 +145,21 @@ private function prepareRiskRequestBody() {
$requestBody['additional']['graphql_operation_name'] = $graphqlFields->getOperationName();
}
+ $crossTabSession = $this->pxCtx->getCrossTabSession();
+ if (isset($crossTabSession)) {
+ $requestBody['additional']['cross_tab_session'] = $crossTabSession;
+ }
+
+ $appUserId = $this->pxCtx->getAppUserId();
+ if (isset($appUserId)) {
+ $requestBody['additional']['app_user_id'] = $appUserId;
+ }
+
+ $jwtAdditionalFields = $this->pxCtx->getJwtAdditionalFields();
+ if (isset($jwtAdditionalFields) && !empty($jwtAdditionalFields)) {
+ $requestBody['additional']['jwt_additional_fields'] = $jwtAdditionalFields;
+ }
+
return $requestBody;
}
diff --git a/src/UserIdentifiers/JwtExtractor.php b/src/UserIdentifiers/JwtExtractor.php
new file mode 100644
index 0000000..1565444
--- /dev/null
+++ b/src/UserIdentifiers/JwtExtractor.php
@@ -0,0 +1,175 @@
+pxConfig = $pxConfig;
+ $this->logger = $pxConfig['logger'];
+ }
+
+ /**
+ * @param array $cookies
+ * @param array $headers
+ * @return array|null
+ */
+ public function extract($cookies, $headers)
+ {
+ $result = $this->extractFromCookie($cookies);
+ if ($result !== null) {
+ return $result;
+ }
+
+ return $this->extractFromHeader($headers);
+ }
+
+ /**
+ * @param array $cookies
+ * @return array|null
+ */
+ private function extractFromCookie($cookies)
+ {
+ $cookieName = $this->pxConfig['px_jwt_cookie_name'];
+ if (empty($cookieName) || !isset($cookies[$cookieName])) {
+ return null;
+ }
+
+ $jwtToken = $cookies[$cookieName];
+ $userIdFieldName = $this->pxConfig['px_jwt_cookie_user_id_field_name'];
+ $additionalFieldNames = $this->pxConfig['px_jwt_cookie_additional_field_names'] ?? [];
+
+ return $this->extractJwtData($jwtToken, $userIdFieldName, $additionalFieldNames);
+ }
+
+ /**
+ * @param array $headers
+ * @return array|null
+ */
+ private function extractFromHeader($headers)
+ {
+ $headerName = $this->pxConfig['px_jwt_header_name'];
+ if (empty($headerName)) {
+ return null;
+ }
+
+ $headersLower = array_change_key_case($headers, CASE_LOWER);
+ $headerNameLower = strtolower($headerName);
+
+ if (!isset($headersLower[$headerNameLower]) || empty($headersLower[$headerNameLower])) {
+ return null;
+ }
+
+ $jwtToken = $headersLower[$headerNameLower];
+ $userIdFieldName = $this->pxConfig['px_jwt_header_user_id_field_name'];
+ $additionalFieldNames = $this->pxConfig['px_jwt_header_additional_field_names'] ?? [];
+
+ return $this->extractJwtData($jwtToken, $userIdFieldName, $additionalFieldNames);
+ }
+
+ /**
+ * @param string $jwtToken
+ * @param string|null $userIdFieldName
+ * @param array $additionalFieldNames
+ * @return array|null
+ */
+ private function extractJwtData($jwtToken, $userIdFieldName, $additionalFieldNames)
+ {
+ try {
+ $payload = $this->decodeJwtPayload($jwtToken);
+ if ($payload === null) {
+ return null;
+ }
+
+ $result = [];
+
+ if (!empty($userIdFieldName)) {
+ $appUserId = $this->extractFieldValue($payload, $userIdFieldName);
+ if ($appUserId !== null) {
+ $result['app_user_id'] = $appUserId;
+ }
+ }
+
+ if (!empty($additionalFieldNames)) {
+ $additionalFields = [];
+ foreach ($additionalFieldNames as $fieldName) {
+ $value = $this->extractFieldValue($payload, $fieldName);
+ if ($value !== null) {
+ $additionalFields[$fieldName] = $value;
+ }
+ }
+ if (!empty($additionalFields)) {
+ $result['jwt_additional_fields'] = $additionalFields;
+ }
+ }
+
+ return !empty($result) ? $result : null;
+ } catch (\Exception $e) {
+ $this->logger->debug('Unable to extract JWT data: ' . $e->getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * @param string $jwtToken
+ * @return array|null
+ */
+ private function decodeJwtPayload($jwtToken)
+ {
+ $parts = explode('.', $jwtToken);
+ if (count($parts) < 3) {
+ return null;
+ }
+
+ $encodedPayload = $parts[1];
+ $base64 = strtr($encodedPayload, '-_', '+/');
+
+ $padLength = 4 - (strlen($base64) % 4);
+ if ($padLength < 4) {
+ $base64 .= str_repeat('=', $padLength);
+ }
+
+ $decoded = base64_decode($base64, true);
+ if ($decoded === false) {
+ return null;
+ }
+
+ $payload = json_decode($decoded, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ return null;
+ }
+
+ return $payload;
+ }
+
+ /**
+ * @param array $payload
+ * @param string $fieldName
+ * @return mixed|null
+ */
+ private function extractFieldValue($payload, $fieldName)
+ {
+ $keys = explode('.', $fieldName);
+ $value = $payload;
+
+ foreach ($keys as $key) {
+ if (!is_array($value) || !array_key_exists($key, $value)) {
+ return null;
+ }
+ $value = $value[$key];
+ }
+
+ return $value;
+ }
+}