├── .gitignore ├── src ├── JwtSessionException.php ├── SessionConfig.php └── JwtSession.php ├── .run ├── Built-in Server for test.run.xml └── PHPUnit.run.xml ├── webtest ├── destroy.php ├── unsetsession.php ├── setsession.php └── index.php ├── psalm.xml ├── composer.json ├── phpunit.xml.dist ├── docs ├── getting-started.md ├── how-it-works.md ├── security.md ├── configuration.md ├── rsa-keys.md └── api-reference.md ├── .github └── workflows │ └── phpunit.yml ├── tests ├── JwtSessionRsaTest.php └── JwtSessionTest.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | composer.lock 3 | vendor 4 | /.phpunit.result.cache 5 | phpunit.coverage.xml 6 | phpunit.report.xml 7 | *.bak 8 | -------------------------------------------------------------------------------- /src/JwtSessionException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.run/PHPUnit.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /webtest/destroy.php: -------------------------------------------------------------------------------- 1 | withSecret('1234567890') 7 | ->replaceSessionHandler(); 8 | 9 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 10 | 11 | session_destroy(); 12 | ?> 13 | 14 |

JwtSession Demo - Destroy whole session

15 | 16 |
17 | Now, your session is empty again. 18 |
19 | 20 |
21 | Go back and check this page: Index 22 |
23 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /webtest/unsetsession.php: -------------------------------------------------------------------------------- 1 | withSecret('1234567890') 7 | ->replaceSessionHandler(); 8 | 9 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 10 | 11 | $count = intval($_SESSION['count']); 12 | 13 | unset($_SESSION['setvalue_' . $count]); 14 | $_SESSION['count'] = $count - 1; 15 | 16 | ?> 17 | 18 |

JwtSession Demo - Set Session

19 | 20 |
21 | Everytime you reach this page I'll remove a session key: setvalue1, setvalue2, ... 22 |
23 | 24 |
25 | Go back and check this page: Index 26 |
27 | -------------------------------------------------------------------------------- /webtest/setsession.php: -------------------------------------------------------------------------------- 1 | withSecret('1234567890') 7 | ->replaceSessionHandler(); 8 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 9 | 10 | $count = intval($_SESSION['count']) + 1; 11 | 12 | $_SESSION['count'] = $count; 13 | $_SESSION['setvalue_' . $count] = 'Set at date ' . date('Y-m-d H:i:s'); 14 | 15 | ?> 16 | 17 |

JwtSession Demo - Set Session

18 | 19 |
20 | Everytime you reach this page I'll create a new key: setvalue1, setvalue2, ... 21 |
22 | 23 |
24 | Go back and check this page: Index 25 |
26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "byjg/jwt-session", 3 | "description": "A PHP session replacement that stores session data in JWT tokens instead of the filesystem. This implementation follows the SessionHandlerInterface standard, enabling stateless sessions without the need for dedicated session servers like Redis or Memcached. Perfect for distributed applications and microservices architectures.", 4 | "autoload": { 5 | "psr-4": { 6 | "ByJG\\Session\\": "src/" 7 | } 8 | }, 9 | "minimum-stability": "dev", 10 | "prefer-stable": true, 11 | "require": { 12 | "php": ">=8.3 <8.6", 13 | "byjg/jwt-wrapper": "^6.0" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "^10.5|^11.5", 17 | "vimeo/psalm": "^5.9|^6.13" 18 | }, 19 | "scripts": { 20 | "test": "vendor/bin/phpunit", 21 | "psalm": "vendor/bin/psalm --threads=1" 22 | }, 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /webtest/index.php: -------------------------------------------------------------------------------- 1 | withSecret('1234567890') 8 | ->replaceSessionHandler(); 9 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 10 | } else { 11 | echo "

JWT Session is disabled

"; 12 | session_start(); 13 | } 14 | 15 | ?> 16 | 17 |

JwtSession Demo

18 | 19 |
20 | Here the user just use the JwtSession as the session handler. 21 | The $_SESSION handler current is: 22 |
23 | 24 |
25 | 27 |
28 | 29 |
30 | Now, play with sessions: 31 | 38 |
39 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ./src 34 | 35 | 36 | 37 | 38 | 39 | ./tests/ 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Getting Started 6 | 7 | JwtSession is a PHP session replacement. Instead of using the FileSystem, it uses JWT TOKEN. The implementation follows the SessionHandlerInterface. 8 | 9 | ## Installation 10 | 11 | Install via Composer: 12 | 13 | ```bash 14 | composer require "byjg/jwt-session" 15 | ``` 16 | 17 | ## Basic Usage 18 | 19 | Before calling `session_start()`, configure and set up the JwtSession handler: 20 | 21 | ```php 22 | withSecret('your super base64url encoded secret key'); 25 | 26 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 27 | session_set_save_handler($handler, true); 28 | ``` 29 | 30 | Now, all your `$_SESSION` variables will be saved directly to a JWT Token! 31 | 32 | ## Secret Key 33 | 34 | Make sure that you are providing a base64url encoded key. 35 | 36 | ## Motivation 37 | 38 | The default PHP Session does not work in different servers using round robin or other algorithms. This occurs because PHP Sessions are saved by default in the file system. 39 | 40 | There are implementations that can save the session to REDIS or MEMCACHED, for example. But this requires you to create a new server to store this session and creates a single point of failure. To avoid this you have to create REDIS/MEMCACHED clusters. 41 | 42 | But if you save the session into JWT Token you do not need to create a new server. Just use it. 43 | 44 | You can read more in this Codementor's article: 45 | [Using JSON Web Token (JWT) as a PHP Session](https://www.codementor.io/byjg/using-json-web-token-jwt-as-a-php-session-axeuqbg1m) 46 | -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - "*.*.*" 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | Build: 14 | runs-on: 'ubuntu-latest' 15 | container: 16 | image: 'byjg/php:${{ matrix.php-version }}-cli' 17 | options: --user root --privileged 18 | strategy: 19 | matrix: 20 | php-version: 21 | - "8.5" 22 | - "8.4" 23 | - "8.3" 24 | 25 | steps: 26 | - uses: actions/checkout@v5 27 | - run: composer install 28 | - run: composer test 29 | 30 | Psalm: 31 | name: Psalm Static Analyzer 32 | runs-on: ubuntu-latest 33 | permissions: 34 | # for github/codeql-action/upload-sarif to upload SARIF results 35 | security-events: write 36 | container: 37 | image: byjg/php:8.4-cli 38 | options: --user root --privileged 39 | 40 | steps: 41 | - name: Git checkout 42 | uses: actions/checkout@v4 43 | 44 | - name: Composer 45 | run: composer install 46 | 47 | - name: Psalm 48 | # Note: Ignoring error code 2, which just signals that some 49 | # flaws were found, not that Psalm itself failed to run. 50 | run: ./vendor/bin/psalm 51 | --show-info=true 52 | --report=psalm-results.sarif || [ $? = 2 ] 53 | 54 | - name: Upload Analysis results to GitHub 55 | uses: github/codeql-action/upload-sarif@v4 56 | if: github.ref == 'refs/heads/master' 57 | with: 58 | sarif_file: psalm-results.sarif 59 | 60 | Documentation: 61 | if: github.ref == 'refs/heads/master' 62 | needs: Build 63 | uses: byjg/byjg.github.io/.github/workflows/add-doc.yaml@master 64 | with: 65 | folder: php 66 | project: ${{ github.event.repository.name }} 67 | secrets: 68 | DOC_TOKEN: ${{ secrets.DOC_TOKEN }} 69 | 70 | -------------------------------------------------------------------------------- /docs/how-it-works.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # How It Works 6 | 7 | ## Cookie Storage 8 | 9 | JwtSession stores a cookie named `AUTH_BEARER_` followed by the context name with the session name. 10 | 11 | The `PHPSESSID` cookie is still created because PHP creates it by default, but we do not use it. 12 | 13 | ## Architecture 14 | 15 | The implementation follows PHP's `SessionHandlerInterface`, which requires implementing the following methods: 16 | 17 | - **`open()`** - Initialize session 18 | - **`close()`** - Close the session 19 | - **`read()`** - Read session data from JWT cookie 20 | - **`write()`** - Write session data to JWT cookie 21 | - **`destroy()`** - Destroy a session by clearing the cookie 22 | - **`gc()`** - Garbage collection (not used in JWT sessions as tokens are self-expiring) 23 | 24 | ## Session Flow 25 | 26 | ### Writing Session Data 27 | 28 | 1. PHP serializes the `$_SESSION` array 29 | 2. The `write()` method receives the serialized data 30 | 3. JwtWrapper creates a JWT token containing the session data 31 | 4. Token is set as a cookie with the configured expiration time 32 | 5. Cookie is sent to the client's browser 33 | 34 | ### Reading Session Data 35 | 36 | 1. Client sends the JWT cookie with the request 37 | 2. The `read()` method extracts the token from the cookie 38 | 3. JwtWrapper validates and decodes the JWT token 39 | 4. Session data is extracted and returned to PHP 40 | 5. PHP unserializes the data into the `$_SESSION` array 41 | 42 | ## Additional Methods 43 | 44 | ### `serializeSessionData(array $array): string` 45 | 46 | Manually serialize session data into PHP session format. Each array entry is serialized as `key|serialized_value`. 47 | 48 | ### `unSerializeSessionData(string $session_data): array` 49 | 50 | Parse PHP session serialized data back into an array. This method handles the custom session serialization format used internally by PHP. 51 | 52 | ## Cookie Configuration 53 | 54 | Cookies are set with the following parameters: 55 | - **Name**: `AUTH_BEARER_{context}` 56 | - **Value**: JWT token containing encrypted session data 57 | - **Expiration**: Current time + configured timeout in minutes 58 | - **Path**: Configured cookie path (default: '/') 59 | - **Domain**: Configured cookie domain 60 | - **Secure**: false (can be enhanced in custom implementations) 61 | - **HttpOnly**: true (prevents JavaScript access) 62 | -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Security Information 6 | 7 | ## Important Security Considerations 8 | 9 | ### JWT Tokens Can Be Read 10 | 11 | The JWT Token cannot be changed, but **it can be read**. 12 | 13 | This implementation saves the JWT into a client cookie. Because of this, **do not store sensible data like passwords in the session**. 14 | 15 | :::danger 16 | Never store sensitive information such as passwords, API keys, or personal identifiable information (PII) in the JWT session. The token is encoded but not encrypted when using shared secrets. 17 | ::: 18 | 19 | ## Best Practices 20 | 21 | ### 1. Use HTTPS 22 | 23 | Always use HTTPS in production to prevent token interception during transmission. 24 | 25 | ### 2. Secret Key Management 26 | 27 | - **Never commit** secret keys to version control 28 | - Use **environment variables** to store secret keys 29 | - Ensure secret keys are **base64url encoded** 30 | - Rotate secret keys periodically 31 | 32 | ### 3. Token Timeout 33 | 34 | Set appropriate timeout values based on your application's security requirements: 35 | 36 | ```php 37 | // For high-security applications 38 | ->withTimeoutMinutes(15) 39 | 40 | // For standard applications 41 | ->withTimeoutMinutes(60) 42 | 43 | // For low-security or development environments 44 | ->withTimeoutHours(24) 45 | ``` 46 | 47 | ### 4. Cookie Security 48 | 49 | When configuring cookies: 50 | - Use specific domain paths to limit cookie scope 51 | - Consider the domain scope carefully (`.example.com` vs `example.com`) 52 | 53 | ### 5. Consider RSA Keys for Enhanced Security 54 | 55 | For applications requiring higher security, use RSA private/public keys instead of shared secrets: 56 | 57 | ```php 58 | ->withRsaSecret($privateKey, $publicKey) 59 | ``` 60 | 61 | This provides: 62 | - Asymmetric encryption 63 | - Better key distribution security 64 | - Enhanced protection against key compromise 65 | 66 | ## What JWT Sessions Protect Against 67 | 68 | - **Session Fixation**: New token generated on each write 69 | - **Server-side Storage Issues**: No server-side session storage required 70 | - **Scaling Issues**: Works seamlessly across multiple servers 71 | - **Token Tampering**: JWT signature prevents token modification 72 | 73 | ## What JWT Sessions Don't Protect Against 74 | 75 | - **Token Theft**: If an attacker obtains the cookie, they can use it 76 | - **XSS Attacks**: Store only non-sensitive data in sessions 77 | - **Man-in-the-Middle**: Always use HTTPS 78 | - **Token Content Privacy**: Token payload is readable (use RSA for better protection) 79 | 80 | ## Exception Handling 81 | 82 | The library throws `JwtSessionException` in the following cases: 83 | - Invalid SessionConfig instance provided 84 | - Session already started when trying to replace handler 85 | - Invalid serialized session data format 86 | 87 | Always implement proper exception handling in production code. 88 | -------------------------------------------------------------------------------- /tests/JwtSessionRsaTest.php: -------------------------------------------------------------------------------- 1 | sessionConfig = (new SessionConfig('example.com')) 59 | ->withRsaSecret($secret, $public); 60 | 61 | $this->object = new JwtSession($this->sessionConfig); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Configuration 6 | 7 | The `SessionConfig` class provides a fluent interface for configuring JWT sessions. 8 | 9 | ## Setting the Validity of JWT Token 10 | 11 | You can set the token timeout in minutes or hours: 12 | 13 | ```php 14 | withSecret('your super base64url encoded secret key') 17 | ->withTimeoutMinutes(60); // You can use withTimeoutHours(1) 18 | 19 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 20 | session_set_save_handler($handler, true); 21 | ``` 22 | 23 | ## Setting Different Session Contexts 24 | 25 | You can create multiple independent session contexts: 26 | 27 | ```php 28 | withSecret('your super base64url encoded secret key') 31 | ->withSessionContext('MYCONTEXT'); 32 | 33 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 34 | session_set_save_handler($handler, true); 35 | ``` 36 | 37 | ## Replace Session Handler Automatically 38 | 39 | You can automatically replace the session handler and start the session: 40 | 41 | ```php 42 | withSecret('your super base64url encoded secret key') 45 | ->replaceSessionHandler(); 46 | 47 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 48 | ``` 49 | 50 | The `replaceSessionHandler()` method accepts an optional parameter: 51 | - `replaceSessionHandler(true)` - Replace the handler and automatically start the session (default) 52 | - `replaceSessionHandler(false)` - Only replace the handler without starting the session 53 | 54 | ## Specify Cookie Domain 55 | 56 | Configure the cookie domain and path: 57 | 58 | ```php 59 | withSecret('your super base64url encoded secret key') 62 | ->withCookie('.mydomain.com', '/') 63 | ->replaceSessionHandler(); 64 | 65 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 66 | ``` 67 | 68 | ## Configuration Methods Reference 69 | 70 | ### `withSecret(string $secret)` 71 | Set the secret key for JWT encoding/decoding. The secret must be base64url encoded. 72 | 73 | ### `withRsaSecret(string $private, string $public)` 74 | Use RSA private/public keys instead of a shared secret. See [RSA Keys](rsa-keys.md) for details. 75 | 76 | ### `withTimeoutMinutes(int $timeout)` 77 | Set the JWT token validity in minutes. Default is 20 minutes. 78 | 79 | ### `withTimeoutHours(int $timeout)` 80 | Set the JWT token validity in hours. Convenience method that converts hours to minutes internally. 81 | 82 | ### `withSessionContext(string $context)` 83 | Set a custom session context name. Default is 'default'. This allows multiple independent sessions. 84 | 85 | ### `withCookie(string $domain, string $path = '/')` 86 | Configure the cookie domain and path. The domain should include the leading dot for subdomain support (e.g., '.example.com'). 87 | 88 | ### `replaceSessionHandler(bool $startSession = true)` 89 | Automatically replace PHP's session handler and optionally start the session immediately. 90 | -------------------------------------------------------------------------------- /docs/rsa-keys.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Using RSA Private/Public Keys 6 | 7 | Instead of using a shared secret, you can use RSA private/public key pairs for enhanced security. 8 | 9 | ## Example 10 | 11 | ```php 12 | withRsaSecret($secret, $public) 57 | ->replaceSessionHandler(); 58 | 59 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 60 | ``` 61 | 62 | ## Generating RSA Keys 63 | 64 | If you want to know more details about how to create RSA Public/Private Keys, visit: 65 | https://github.com/byjg/jwt-wrapper 66 | 67 | ## Benefits of RSA Keys 68 | 69 | - **Enhanced Security**: Private key never needs to be shared with clients 70 | - **Better Key Management**: Public key can be distributed safely for verification 71 | - **Stronger Cryptography**: RSA provides asymmetric encryption capabilities 72 | -------------------------------------------------------------------------------- /src/SessionConfig.php: -------------------------------------------------------------------------------- 1 | serverName = $serverName; 27 | } 28 | 29 | public function withSessionContext($context): static 30 | { 31 | $this->sessionContext = $context; 32 | return $this; 33 | } 34 | 35 | public function withTimeoutMinutes($timeout): static 36 | { 37 | $this->timeoutMinutes = $timeout; 38 | return $this; 39 | } 40 | 41 | public function withTimeoutHours($timeout): static 42 | { 43 | $this->timeoutMinutes = $timeout * 60; 44 | return $this; 45 | } 46 | 47 | public function withCookie($domain, $path = "/"): static 48 | { 49 | $this->cookieDomain = $domain; 50 | $this->cookiePath = $path; 51 | return $this; 52 | } 53 | 54 | public function withSecret($secret): static 55 | { 56 | $this->jwtKey = new JwtHashHmacSecret($secret); 57 | return $this; 58 | } 59 | 60 | public function withRsaSecret($private, $public): static 61 | { 62 | $this->jwtKey = new JwtOpenSSLKey($private, $public); 63 | return $this; 64 | } 65 | 66 | public function replaceSessionHandler($startSession = true): static 67 | { 68 | $this->replaceSessionHandler = $startSession; 69 | return $this; 70 | } 71 | 72 | /** 73 | * @return string 74 | */ 75 | public function getServerName(): string 76 | { 77 | return $this->serverName; 78 | } 79 | 80 | /** 81 | * @return string 82 | */ 83 | public function getSessionContext(): string 84 | { 85 | return $this->sessionContext; 86 | } 87 | 88 | /** 89 | * @return int 90 | */ 91 | public function getTimeoutMinutes(): int 92 | { 93 | return $this->timeoutMinutes; 94 | } 95 | 96 | /** 97 | * @return string|null 98 | */ 99 | public function getCookieDomain(): ?string 100 | { 101 | return $this->cookieDomain; 102 | } 103 | 104 | /** 105 | * @return string 106 | */ 107 | public function getCookiePath(): string 108 | { 109 | return $this->cookiePath; 110 | } 111 | 112 | /** 113 | * @return JwtKeyInterface|null 114 | */ 115 | public function getKey(): ?JwtKeyInterface 116 | { 117 | return $this->jwtKey; 118 | } 119 | 120 | public function isReplaceSession(): bool 121 | { 122 | return $this->replaceSessionHandler !== null; 123 | } 124 | 125 | public function isStartSession(): bool 126 | { 127 | return $this->replaceSessionHandler === true; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/JwtSessionTest.php: -------------------------------------------------------------------------------- 1 | sessionConfig = (new SessionConfig('example.com')) 33 | ->withSecret('secretKey'); 34 | 35 | $this->object = new JwtSession($this->sessionConfig); 36 | } 37 | 38 | protected function tearDown(): void 39 | { 40 | header_remove(); 41 | $_COOKIE = []; 42 | $this->object = null; 43 | } 44 | 45 | 46 | public function testDestroy() 47 | { 48 | $this->assertTrue($this->object->destroy(self::SESSION_ID)); 49 | } 50 | 51 | public function testGc() 52 | { 53 | $this->assertEquals(1, $this->object->gc(0)); 54 | } 55 | 56 | public function testClose() 57 | { 58 | $this->assertTrue($this->object->close()); 59 | } 60 | 61 | public static function dataProvider(): array 62 | { 63 | $obj = new stdClass(); 64 | $obj->prop1 = "value1"; 65 | $obj->prop2 = "value2"; 66 | 67 | return 68 | [ 69 | [ 70 | [ 71 | "text" => "simple string" 72 | ], 73 | "text|s:13:\"simple string\";" 74 | ], 75 | [ 76 | [ 77 | "text" => "simple string", 78 | "text2" => "another string", 79 | "number" => 74 80 | ], 81 | "text|s:13:\"simple string\";text2|s:14:\"another string\";number|i:74;" 82 | ], 83 | [ 84 | [ 85 | "text" => [ 1, 2, 3 ] 86 | ], 87 | "text|a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}" 88 | ], 89 | [ 90 | [ 91 | "text" => [ "a" => 1, "b" => 2, "c" => 3 ] 92 | ], 93 | "text|a:3:{s:1:\"a\";i:1;s:1:\"b\";i:2;s:1:\"c\";i:3;}" 94 | ], 95 | [ 96 | [ 97 | "text" => [ "a" => 1, "b" => 2, "c" => 3 ], 98 | "single" => 2000 99 | ], 100 | "text|a:3:{s:1:\"a\";i:1;s:1:\"b\";i:2;s:1:\"c\";i:3;}single|i:2000;" 101 | ], 102 | [ 103 | [ 104 | "text" => $obj 105 | ], 106 | "text|O:8:\"stdClass\":2:{s:5:\"prop1\";s:6:\"value1\";s:5:\"prop2\";s:6:\"value2\";}" 107 | ], 108 | [ 109 | [ 110 | "text" => [ "a" => $obj ] 111 | ], 112 | "text|a:1:{s:1:\"a\";O:8:\"stdClass\":2:{s:5:\"prop1\";s:6:\"value1\";s:5:\"prop2\";s:6:\"value2\";}}" 113 | ], 114 | [ 115 | [ 116 | "text" => [ $obj ] 117 | ], 118 | "text|a:1:{i:0;O:8:\"stdClass\":2:{s:5:\"prop1\";s:6:\"value1\";s:5:\"prop2\";s:6:\"value2\";}}" 119 | ] 120 | ]; 121 | } 122 | 123 | #[DataProvider('dataProvider')] 124 | public function testSerializeSessionData($input, $expected) 125 | { 126 | $result = $this->object->serializeSessionData($input); 127 | $this->assertEquals($expected, $result); 128 | } 129 | 130 | #[DataProvider('dataProvider')] 131 | public function testUnserializeData($expected, $input) 132 | { 133 | $result = $this->object->unSerializeSessionData($input); 134 | $this->assertEquals($expected, $result); 135 | } 136 | 137 | #[DataProvider('dataProvider')] 138 | public function testReadWrite($object, $serialize) 139 | { 140 | $this->object->write("SESSID", $serialize); 141 | $result = $this->object->read("SESSID"); 142 | $this->assertEquals($serialize, $result); 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JWT Session Handler 2 | 3 | [![Sponsor](https://img.shields.io/badge/Sponsor-%23ea4aaa?logo=githubsponsors&logoColor=white&labelColor=0d1117)](https://github.com/sponsors/byjg) 4 | [![Build Status](https://github.com/byjg/jwt-session/actions/workflows/phpunit.yml/badge.svg?branch=master)](https://github.com/byjg/jwt-session/actions/workflows/phpunit.yml) 5 | [![Opensource ByJG](https://img.shields.io/badge/opensource-byjg-success.svg)](http://opensource.byjg.com) 6 | [![GitHub source](https://img.shields.io/badge/Github-source-informational?logo=github)](https://github.com/byjg/jwt-session/) 7 | [![GitHub license](https://img.shields.io/github/license/byjg/jwt-session.svg)](https://opensource.byjg.com/opensource/licensing.html) 8 | [![GitHub release](https://img.shields.io/github/release/byjg/jwt-session.svg)](https://github.com/byjg/jwt-session/releases/) 9 | 10 | A PHP session replacement that stores session data in JWT tokens instead of the filesystem. This implementation follows the SessionHandlerInterface standard, enabling stateless sessions without the need for dedicated session servers like Redis or Memcached. Perfect for distributed applications and microservices architectures. 11 | 12 | ## Documentation 13 | 14 | - [Getting Started](docs/getting-started.md) - Installation, basic usage, and motivation 15 | - [Configuration](docs/configuration.md) - Session timeout, contexts, cookies, and all configuration options 16 | - [RSA Keys](docs/rsa-keys.md) - Using RSA private/public keys for enhanced security 17 | - [How It Works](docs/how-it-works.md) - Architecture and internal implementation details 18 | - [Security](docs/security.md) - Security considerations and best practices 19 | - [API Reference](docs/api-reference.md) - Complete API documentation for all classes and methods 20 | 21 | # How to use: 22 | 23 | Before the session_start() use the command: 24 | 25 | ```php 26 | withSecret('your super base64url encoded secret key'); 29 | 30 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 31 | session_set_save_handler($handler, true); 32 | ``` 33 | 34 | Now, all your `$_SESSION` variable will be saved directly to a JWT Token!! 35 | 36 | **Note:** Make sure that you are providing a base64url encoded key. 37 | 38 | For more details on motivation, security considerations, and best practices, see the [Documentation](#documentation) section above. 39 | 40 | # Install 41 | 42 | ``` 43 | composer require "byjg/jwt-session" 44 | ``` 45 | 46 | # Configuration Examples 47 | 48 | ## Setting the validity of JWT Token 49 | 50 | ```php 51 | withSecret('your super base64url encoded secret key') 54 | ->withTimeoutMinutes(60); // You can use withTimeoutHours(1) 55 | 56 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 57 | session_set_save_handler($handler, true); 58 | ``` 59 | 60 | ## Setting different Session Contexts 61 | 62 | ```php 63 | withSecret('your super base64url encoded secret key') 66 | ->withSessionContext('MYCONTEXT'); 67 | 68 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 69 | session_set_save_handler($handler, true); 70 | ``` 71 | 72 | For complete configuration options including cookie domains and automatic session handler replacement, see [Configuration](docs/configuration.md). 73 | 74 | ## Using RSA Private/Public Keys 75 | 76 | ```php 77 | withRsaSecret($secret, $public) 122 | ->replaceSessionHandler(); 123 | 124 | $handler = new \ByJG\Session\JwtSession($sessionConfig); 125 | ``` 126 | 127 | For more details about RSA keys and how to generate them, see [RSA Keys](docs/rsa-keys.md) and https://github.com/byjg/jwt-wrapper 128 | 129 | 130 | ## Dependencies 131 | 132 | ```mermaid 133 | flowchart TD 134 | byjg/jwt-session --> byjg/jwt-wrapper 135 | ``` 136 | 137 | ---- 138 | [Open source ByJG](http://opensource.byjg.com) 139 | -------------------------------------------------------------------------------- /docs/api-reference.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # API Reference 6 | 7 | ## Classes 8 | 9 | ### `ByJG\Session\JwtSession` 10 | 11 | Implements PHP's `SessionHandlerInterface` to provide JWT-based session storage. 12 | 13 | #### Constants 14 | 15 | - **`COOKIE_PREFIX`** = `"AUTH_BEARER_"` 16 | Prefix for the session cookie name. 17 | 18 | #### Constructor 19 | 20 | ```php 21 | public function __construct(SessionConfig $sessionConfig) 22 | ``` 23 | 24 | **Parameters:** 25 | - `$sessionConfig` (SessionConfig): Configuration object for the JWT session 26 | 27 | **Throws:** 28 | - `JwtSessionException`: If SessionConfig instance is invalid or session already started 29 | 30 | #### SessionHandlerInterface Methods 31 | 32 | ##### `open(string $path, string $name): bool` 33 | 34 | Initialize the session (no-op in JWT implementation). 35 | 36 | ##### `close(): bool` 37 | 38 | Close the session (no-op in JWT implementation). 39 | 40 | ##### `read(string $id): string` 41 | 42 | Read session data from JWT cookie. 43 | 44 | **Parameters:** 45 | - `$id` (string): Session ID (not used in JWT implementation) 46 | 47 | **Returns:** Serialized session data string, or empty string if no session exists 48 | 49 | ##### `write(string $id, string $data): bool` 50 | 51 | Write session data to JWT cookie. 52 | 53 | **Parameters:** 54 | - `$id` (string): Session ID (not used in JWT implementation) 55 | - `$data` (string): Serialized session data from PHP 56 | 57 | **Returns:** true on success 58 | 59 | **Throws:** 60 | - `JwtWrapperException`: If JWT token generation fails 61 | 62 | ##### `destroy(string $id): bool` 63 | 64 | Destroy the session by clearing the JWT cookie. 65 | 66 | **Parameters:** 67 | - `$id` (string): Session ID to destroy 68 | 69 | **Returns:** true on success 70 | 71 | ##### `gc(int $max_lifetime): int|false` 72 | 73 | Garbage collection (no-op in JWT implementation as tokens are self-expiring). 74 | 75 | **Parameters:** 76 | - `$max_lifetime` (int): Maximum session lifetime 77 | 78 | **Returns:** true 79 | 80 | #### Public Helper Methods 81 | 82 | ##### `serializeSessionData(array $array): string` 83 | 84 | Manually serialize session data into PHP session format. 85 | 86 | **Parameters:** 87 | - `$array` (array): Associative array of session data 88 | 89 | **Returns:** Serialized string in PHP session format 90 | 91 | ##### `unSerializeSessionData(string $session_data): array` 92 | 93 | Parse PHP session serialized data back into an array. 94 | 95 | **Parameters:** 96 | - `$session_data` (string): Serialized session data 97 | 98 | **Returns:** Associative array of session variables 99 | 100 | **Throws:** 101 | - `JwtSessionException`: If session data format is invalid 102 | 103 | --- 104 | 105 | ### `ByJG\Session\SessionConfig` 106 | 107 | Configuration class for JWT sessions with fluent interface. 108 | 109 | #### Constructor 110 | 111 | ```php 112 | public function __construct(string $serverName) 113 | ``` 114 | 115 | **Parameters:** 116 | - `$serverName` (string): Server name/domain for JWT token 117 | 118 | #### Configuration Methods 119 | 120 | ##### `withSecret(string $secret): static` 121 | 122 | Set the secret key for JWT encoding/decoding. 123 | 124 | **Parameters:** 125 | - `$secret` (string): Base64url encoded secret key 126 | 127 | **Returns:** Self for method chaining 128 | 129 | ##### `withRsaSecret(string $private, string $public): static` 130 | 131 | Use RSA private/public keys for JWT encoding/decoding. 132 | 133 | **Parameters:** 134 | - `$private` (string): RSA private key (PEM format) 135 | - `$public` (string): RSA public key (PEM format) 136 | 137 | **Returns:** Self for method chaining 138 | 139 | ##### `withTimeoutMinutes(int $timeout): static` 140 | 141 | Set JWT token validity in minutes. 142 | 143 | **Parameters:** 144 | - `$timeout` (int): Timeout in minutes (default: 20) 145 | 146 | **Returns:** Self for method chaining 147 | 148 | ##### `withTimeoutHours(int $timeout): static` 149 | 150 | Set JWT token validity in hours. 151 | 152 | **Parameters:** 153 | - `$timeout` (int): Timeout in hours 154 | 155 | **Returns:** Self for method chaining 156 | 157 | ##### `withSessionContext(string $context): static` 158 | 159 | Set custom session context name. 160 | 161 | **Parameters:** 162 | - `$context` (string): Context name (default: 'default') 163 | 164 | **Returns:** Self for method chaining 165 | 166 | ##### `withCookie(string $domain, string $path = '/'): static` 167 | 168 | Configure cookie domain and path. 169 | 170 | **Parameters:** 171 | - `$domain` (string): Cookie domain (e.g., '.example.com') 172 | - `$path` (string): Cookie path (default: '/') 173 | 174 | **Returns:** Self for method chaining 175 | 176 | ##### `replaceSessionHandler(bool $startSession = true): static` 177 | 178 | Configure automatic session handler replacement. 179 | 180 | **Parameters:** 181 | - `$startSession` (bool): Whether to start session automatically (default: true) 182 | 183 | **Returns:** Self for method chaining 184 | 185 | #### Getter Methods 186 | 187 | ##### `getServerName(): string` 188 | 189 | Get the configured server name. 190 | 191 | ##### `getSessionContext(): string` 192 | 193 | Get the session context name. 194 | 195 | ##### `getTimeoutMinutes(): int` 196 | 197 | Get the timeout in minutes. 198 | 199 | ##### `getCookieDomain(): ?string` 200 | 201 | Get the cookie domain. 202 | 203 | ##### `getCookiePath(): string` 204 | 205 | Get the cookie path. 206 | 207 | ##### `getKey(): ?JwtKeyInterface` 208 | 209 | Get the JWT key interface instance. 210 | 211 | ##### `isReplaceSession(): bool` 212 | 213 | Check if session handler replacement is configured. 214 | 215 | ##### `isStartSession(): bool` 216 | 217 | Check if automatic session start is enabled. 218 | 219 | --- 220 | 221 | ### `ByJG\Session\JwtSessionException` 222 | 223 | Exception class for JWT session errors. 224 | 225 | **Extends:** `Exception` 226 | 227 | **Use cases:** 228 | - Invalid SessionConfig provided 229 | - Session already started 230 | - Invalid serialized session data 231 | 232 | ## Example Usage 233 | 234 | ### Basic Implementation 235 | 236 | ```php 237 | withSecret('your-base64url-encoded-secret') 244 | ->withTimeoutMinutes(30) 245 | ->withCookie('.example.com', '/'); 246 | 247 | $handler = new JwtSession($config); 248 | session_set_save_handler($handler, true); 249 | session_start(); 250 | 251 | // Use $_SESSION as normal 252 | $_SESSION['user_id'] = 123; 253 | 254 | } catch (JwtSessionException $e) { 255 | // Handle exception 256 | error_log('Session error: ' . $e->getMessage()); 257 | } 258 | ``` 259 | 260 | ### With Automatic Handler Replacement 261 | 262 | ```php 263 | withSecret('your-base64url-encoded-secret') 266 | ->replaceSessionHandler(true); // Automatically starts session 267 | 268 | $handler = new JwtSession($config); 269 | 270 | // Session is already started, use $_SESSION directly 271 | $_SESSION['user_id'] = 123; 272 | ``` 273 | -------------------------------------------------------------------------------- /src/JwtSession.php: -------------------------------------------------------------------------------- 1 | sessionConfig = $sessionConfig; 34 | 35 | if ($this->sessionConfig->isReplaceSession()) { 36 | $this->replaceSessionHandler(); 37 | } 38 | } 39 | 40 | /** 41 | * @throws JwtSessionException 42 | */ 43 | protected function replaceSessionHandler(): void 44 | { 45 | if (session_status() != PHP_SESSION_NONE) { 46 | throw new JwtSessionException('Session already started!'); 47 | } 48 | 49 | session_set_save_handler($this, true); 50 | 51 | if ($this->sessionConfig->isStartSession()) { 52 | ob_start(); 53 | session_start(); 54 | } 55 | } 56 | 57 | /** 58 | * Close the session 59 | * 60 | * @link http://php.net/manual/en/sessionhandlerinterface.close.php 61 | * @return bool

62 | * The return value (usually TRUE on success, FALSE on failure). 63 | * Note this value is returned internally to PHP for processing. 64 | *

65 | * @since 5.4.0 66 | */ 67 | #[\Override] 68 | public function close(): bool 69 | { 70 | return true; 71 | } 72 | 73 | /** 74 | * Destroy a session 75 | * 76 | * @link http://php.net/manual/en/sessionhandlerinterface.destroy.php 77 | * @param string $id The session ID being destroyed. 78 | * @return bool

79 | * The return value (usually TRUE on success, FALSE on failure). 80 | * Note this value is returned internally to PHP for processing. 81 | *

82 | * @since 5.4.0 83 | */ 84 | #[\Override] 85 | public function destroy(string $id): bool 86 | { 87 | if (!headers_sent()) { 88 | setcookie( 89 | self::COOKIE_PREFIX . $this->sessionConfig->getSessionContext(), 90 | "", 91 | (time()-3000), 92 | $this->sessionConfig->getCookiePath(), 93 | $this->sessionConfig->getCookieDomain() ?? "", 94 | ); 95 | } 96 | 97 | return true; 98 | } 99 | 100 | /** 101 | * Cleanup old sessions 102 | * 103 | * @link http://php.net/manual/en/sessionhandlerinterface.gc.php 104 | * 105 | * @param int $max_lifetime

106 | * Sessions that have not updated for 107 | * the last maxlifetime seconds will be removed. 108 | *

109 | * 110 | * @return int|false

The return value (usually TRUE on success, FALSE on failure). Note this value is returned internally to PHP for processing.

111 | * 112 | * @since 5.4.0 113 | */ 114 | #[\Override] 115 | public function gc(int $max_lifetime): int|false 116 | { 117 | return 1; 118 | } 119 | 120 | /** 121 | * Initialize session 122 | * 123 | * @link http://php.net/manual/en/sessionhandlerinterface.open.php 124 | * @param string $path The path where to store/retrieve the session. 125 | * @param string $name The session name. 126 | * @return bool

127 | * The return value (usually TRUE on success, FALSE on failure). 128 | * Note this value is returned internally to PHP for processing. 129 | *

130 | * @since 5.4.0 131 | */ 132 | #[\Override] 133 | public function open(string $path, string $name): bool 134 | { 135 | return true; 136 | } 137 | 138 | /** 139 | * Read session data 140 | * 141 | * @link http://php.net/manual/en/sessionhandlerinterface.read.php 142 | * @param string $id The session id to read data for. 143 | * @return string

144 | * Returns an encoded string of the read data. 145 | * If nothing was read, it must return an empty string. 146 | * Note this value is returned internally to PHP for processing. 147 | *

148 | * @since 5.4.0 149 | */ 150 | #[\Override] 151 | public function read(string $id): string 152 | { 153 | try { 154 | if (isset($_COOKIE[self::COOKIE_PREFIX . $this->sessionConfig->getSessionContext()])) { 155 | $key = $this->sessionConfig->getKey(); 156 | if ($key === null) { 157 | return ''; 158 | } 159 | $jwt = new JwtWrapper( 160 | $this->sessionConfig->getServerName(), 161 | $key 162 | ); 163 | $data = $jwt->extractData($_COOKIE[self::COOKIE_PREFIX . $this->sessionConfig->getSessionContext()]); 164 | 165 | if (empty($data->data)) { 166 | return ''; 167 | } 168 | 169 | return $data->data; 170 | } 171 | return ''; 172 | } catch (Exception $ex) { 173 | return ''; 174 | } 175 | } 176 | 177 | /** 178 | * Write session data 179 | * 180 | * @link http://php.net/manual/en/sessionhandlerinterface.write.php 181 | * @param string $id The session id. 182 | * @param string $data

183 | * The encoded session data. This data is the 184 | * result of the PHP internally encoding 185 | * the $_SESSION superglobal to a serialized 186 | * string and passing it as this parameter. 187 | * Please note sessions use an alternative serialization method. 188 | *

189 | * @return bool

190 | * The return value (usually TRUE on success, FALSE on failure). 191 | * Note this value is returned internally to PHP for processing. 192 | *

193 | * @throws JwtWrapperException 194 | * @since 5.4.0 195 | */ 196 | #[\Override] 197 | public function write(string $id, string $data): bool 198 | { 199 | $key = $this->sessionConfig->getKey(); 200 | if ($key === null) { 201 | return false; 202 | } 203 | $jwt = new JwtWrapper( 204 | $this->sessionConfig->getServerName(), 205 | $key 206 | ); 207 | $session_data = $jwt->createJwtData(['data' => $data], $this->sessionConfig->getTimeoutMinutes() * 60, 0, null); 208 | $token = $jwt->generateToken($session_data); 209 | 210 | if (!headers_sent()) { 211 | setcookie( 212 | self::COOKIE_PREFIX . $this->sessionConfig->getSessionContext(), 213 | $token, 214 | (time()+$this->sessionConfig->getTimeoutMinutes()*60) , 215 | $this->sessionConfig->getCookiePath(), 216 | $this->sessionConfig->getCookieDomain() ?? "", 217 | false, 218 | true 219 | ); 220 | if (defined("SETCOOKIE_FORTEST")) { 221 | $_COOKIE[self::COOKIE_PREFIX . $this->sessionConfig->getSessionContext()] = $token; 222 | } 223 | } 224 | 225 | return true; 226 | } 227 | 228 | public function serializeSessionData($array): string 229 | { 230 | $result = ''; 231 | foreach ($array as $key => $value) { 232 | $result .= $key . "|" . serialize($value); 233 | } 234 | 235 | return $result; 236 | } 237 | 238 | /** 239 | * @param $session_data 240 | * @return array 241 | * @throws JwtSessionException 242 | */ 243 | public function unSerializeSessionData($session_data): array 244 | { 245 | $return_data = array(); 246 | $offset = 0; 247 | while ($offset < strlen($session_data)) { 248 | if (!str_contains(substr($session_data, $offset), "|")) throw new JwtSessionException("invalid data, remaining: " . substr($session_data, $offset)); 249 | $pos = strpos($session_data, "|", $offset); 250 | if ($pos === false) { 251 | throw new JwtSessionException("invalid data, pipe not found"); 252 | } 253 | $num = $pos - $offset; 254 | $varname = substr($session_data, $offset, $num); 255 | $offset += $num + 1; 256 | $data = @unserialize(substr($session_data, $offset), ['allowed_classes' => true]); 257 | $return_data[$varname] = $data; 258 | $offset += strlen(serialize($data)); 259 | } 260 | 261 | return $return_data; 262 | } 263 | } 264 | --------------------------------------------------------------------------------