├── .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 | [](https://github.com/sponsors/byjg)
4 | [](https://github.com/byjg/jwt-session/actions/workflows/phpunit.yml)
5 | [](http://opensource.byjg.com)
6 | [](https://github.com/byjg/jwt-session/)
7 | [](https://opensource.byjg.com/opensource/licensing.html)
8 | [](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 |
--------------------------------------------------------------------------------