diff --git a/docker/webapp/Dockerfile b/docker/webapp/Dockerfile
index 7f142b82..cfbd755a 100755
--- a/docker/webapp/Dockerfile
+++ b/docker/webapp/Dockerfile
@@ -1,17 +1,17 @@
 FROM apheleia/swoole:latest
 
 MAINTAINER Jeroen van Meeuwen <vanmeeuwen@apheleia-it.ch>
 
 USER root
 
-RUN dnf -y install findutils gnupg2 git rsync procps-ng
+RUN dnf -y install findutils gnupg2 git rsync procps-ng php-sodium
 
 EXPOSE 8000
 
 ARG GIT_REF=master
 COPY build.sh /build.sh
 RUN /build.sh
 COPY init.sh /init.sh
 COPY update.sh /update.sh
 
 CMD [ "/init.sh" ]
diff --git a/src/app/Http/Controllers/API/V4/VPNController.php b/src/app/Http/Controllers/API/V4/VPNController.php
new file mode 100644
index 00000000..f8ee8d2e
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/VPNController.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Lcobucci\JWT\Encoding\ChainedFormatter;
+use Lcobucci\JWT\Encoding\JoseEncoder;
+use Lcobucci\JWT\Signer\Key\InMemory;
+use Lcobucci\JWT\Signer\Rsa;
+use Lcobucci\JWT\Token\Builder;
+
+class VPNController extends Controller
+{
+    /**
+     * Token request from the vpn module
+     *
+     * @param \Illuminate\Http\Request $request The API request.
+     *
+     * @return \Illuminate\Http\JsonResponse The response
+     */
+    public function token(Request $request)
+    {
+        $signingKey = \config("app.vpn.token_signing_key");
+        if (empty($signingKey)) {
+            throw new \Exception("app.vpn.token_signing_key is not set");
+        }
+
+        $tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default()));
+        $token = $tokenBuilder
+            ->issuedAt(Carbon::now()->toImmutable())
+            // The entitlement is hardcoded for now to default.
+            // Can be extended in the future based on user entitlements.
+            ->withClaim('entitlement', "default")
+            ->getToken(new Rsa\Sha256(), InMemory::plainText($signingKey));
+
+        return response()->json(['status' => 'ok', 'token' => $token->toString()]);
+    }
+}
diff --git a/src/composer.json b/src/composer.json
index 47448f29..2bef1fe2 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,86 +1,87 @@
 {
     "name": "kolab/kolab4",
     "type": "project",
     "description": "Kolab 4",
     "keywords": [
         "framework",
         "laravel"
     ],
     "license": "MIT",
     "repositories": [
         {
             "type": "vcs",
             "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
         }
     ],
     "require": {
         "php": "^8.1",
         "bacon/bacon-qr-code": "^2.0",
         "barryvdh/laravel-dompdf": "^2.0.0",
         "doctrine/dbal": "^3.3.2",
         "dyrynda/laravel-nullable-fields": "^4.2.0",
         "guzzlehttp/guzzle": "^7.4.1",
         "kolab/net_ldap3": "dev-master",
         "laravel/framework": "^9.2",
         "laravel/horizon": "^5.9",
         "laravel/octane": "^1.2",
         "laravel/passport": "^11.3",
         "laravel/tinker": "^2.7",
         "league/flysystem-aws-s3-v3": "^3.0",
         "mlocati/spf-lib": "^3.1",
         "mollie/laravel-mollie": "^2.19",
         "pear/crypt_gpg": "^1.6.6",
         "predis/predis": "^2.0",
         "sabre/vobject": "^4.5",
         "spatie/laravel-translatable": "^6.3",
         "spomky-labs/otphp": "~10.0.0",
-        "stripe/stripe-php": "^10.7"
+        "stripe/stripe-php": "^10.7",
+        "lcobucci/jwt": "^5.0"
     },
     "require-dev": {
         "code-lts/doctum": "^5.5.1",
         "laravel/dusk": "~7.5.0",
         "mockery/mockery": "^1.5",
         "nunomaduro/larastan": "^2.0",
         "phpstan/phpstan": "^1.4",
         "phpunit/phpunit": "^9",
         "squizlabs/php_codesniffer": "^3.6"
     },
     "config": {
         "optimize-autoloader": true,
         "preferred-install": "dist",
         "sort-packages": true
     },
     "extra": {
         "laravel": {
             "dont-discover": []
         }
     },
     "autoload": {
         "psr-4": {
             "App\\": "app/"
         },
         "classmap": [
             "database/seeds",
             "include"
         ]
     },
     "autoload-dev": {
         "psr-4": {
             "Tests\\": "tests/"
         }
     },
     "minimum-stability": "stable",
     "prefer-stable": true,
     "scripts": {
         "post-autoload-dump": [
             "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
             "@php artisan package:discover --ansi"
         ],
         "post-update-cmd": [
             "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
         ],
         "post-create-project-cmd": [
             "@php artisan key:generate --ansi"
         ]
     }
 }
diff --git a/src/config/app.php b/src/config/app.php
index 7ae1aad5..888a3a53 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,284 +1,288 @@
 <?php
 
 return [
 
     /*
     |--------------------------------------------------------------------------
     | Application Name
     |--------------------------------------------------------------------------
     |
     | This value is the name of your application. This value is used when the
     | framework needs to place the application's name in a notification or
     | any other location as required by the application or its packages.
     |
     */
 
     'name' => env('APP_NAME', 'Laravel'),
 
     /*
     |--------------------------------------------------------------------------
     | Application Environment
     |--------------------------------------------------------------------------
     |
     | This value determines the "environment" your application is currently
     | running in. This may determine how you prefer to configure various
     | services the application utilizes. Set this in your ".env" file.
     |
     */
 
     'env' => env('APP_ENV', 'production'),
 
     /*
     |--------------------------------------------------------------------------
     | Application Debug Mode
     |--------------------------------------------------------------------------
     |
     | When your application is in debug mode, detailed error messages with
     | stack traces will be shown on every error that occurs within your
     | application. If disabled, a simple generic error page is shown.
     |
     */
 
     'debug' => env('APP_DEBUG', false),
 
     /*
     |--------------------------------------------------------------------------
     | Application URL
     |--------------------------------------------------------------------------
     |
     | This URL is used by the console to properly generate URLs when using
     | the Artisan command line tool. You should set this to the root of
     | your application so that it is used when running Artisan tasks.
     */
 
     'url' => env('APP_URL', 'http://localhost'),
 
     'passphrase' => env('APP_PASSPHRASE', null),
 
     'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
 
     'asset_url' => env('ASSET_URL'),
 
     'support_url' => env('SUPPORT_URL', null),
 
     'support_email' => env('SUPPORT_EMAIL', null),
 
     'webmail_url' => env('WEBMAIL_URL', null),
 
     'theme' => env('APP_THEME', 'default'),
 
     'tenant_id' => env('APP_TENANT_ID', null),
 
     'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')),
 
     /*
     |--------------------------------------------------------------------------
     | Application Domain
     |--------------------------------------------------------------------------
     |
     | System domain used for user signup (kolab identity)
     */
     'domain' => env('APP_DOMAIN', 'domain.tld'),
 
     'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')),
 
     'services_domain' => env(
         'APP_SERVICES_DOMAIN',
         "services." . env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld'))
     ),
 
     /*
     |--------------------------------------------------------------------------
     | Application Timezone
     |--------------------------------------------------------------------------
     |
     | Here you may specify the default timezone for your application, which
     | will be used by the PHP date and date-time functions. We have gone
     | ahead and set this to a sensible default for you out of the box.
     |
     */
 
     'timezone' => 'UTC',
 
     /*
     |--------------------------------------------------------------------------
     | Application Locale Configuration
     |--------------------------------------------------------------------------
     |
     | The application locale determines the default locale that will be used
     | by the translation service provider. You are free to set this value
     | to any of the locales which will be supported by the application.
     |
     */
 
     'locale' => env('APP_LOCALE', 'en'),
 
     /*
     |--------------------------------------------------------------------------
     | Application Fallback Locale
     |--------------------------------------------------------------------------
     |
     | The fallback locale determines the locale to use when the current one
     | is not available. You may change the value to correspond to any of
     | the language folders that are provided through your application.
     |
     */
 
     'fallback_locale' => 'en',
 
     /*
     |--------------------------------------------------------------------------
     | Faker Locale
     |--------------------------------------------------------------------------
     |
     | This locale will be used by the Faker PHP library when generating fake
     | data for your database seeds. For example, this will be used to get
     | localized telephone numbers, street address information and more.
     |
     */
 
     'faker_locale' => 'en_US',
 
     /*
     |--------------------------------------------------------------------------
     | Encryption Key
     |--------------------------------------------------------------------------
     |
     | This key is used by the Illuminate encrypter service and should be set
     | to a random, 32 character string, otherwise these encrypted strings
     | will not be safe. Please do this before deploying an application!
     |
     */
 
     'key' => env('APP_KEY'),
 
     'cipher' => 'AES-256-CBC',
 
     /*
     |--------------------------------------------------------------------------
     | Autoloaded Service Providers
     |--------------------------------------------------------------------------
     |
     | The service providers listed here will be automatically loaded on the
     | request to your application. Feel free to add your own services to
     | this array to grant expanded functionality to your applications.
     |
     */
 
     'providers' => [
         /*
          * Laravel Framework Service Providers...
          */
         Illuminate\Auth\AuthServiceProvider::class,
         Illuminate\Broadcasting\BroadcastServiceProvider::class,
         Illuminate\Bus\BusServiceProvider::class,
         Illuminate\Cache\CacheServiceProvider::class,
         Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
         Illuminate\Cookie\CookieServiceProvider::class,
         Illuminate\Database\DatabaseServiceProvider::class,
         Illuminate\Encryption\EncryptionServiceProvider::class,
         Illuminate\Filesystem\FilesystemServiceProvider::class,
         Illuminate\Foundation\Providers\FoundationServiceProvider::class,
         Illuminate\Hashing\HashServiceProvider::class,
         Illuminate\Mail\MailServiceProvider::class,
         Illuminate\Notifications\NotificationServiceProvider::class,
         Illuminate\Pagination\PaginationServiceProvider::class,
         Illuminate\Pipeline\PipelineServiceProvider::class,
         Illuminate\Queue\QueueServiceProvider::class,
         Illuminate\Redis\RedisServiceProvider::class,
         Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
         Illuminate\Session\SessionServiceProvider::class,
         Illuminate\Translation\TranslationServiceProvider::class,
         Illuminate\Validation\ValidationServiceProvider::class,
         Illuminate\View\ViewServiceProvider::class,
 
         /*
          * Application Service Providers...
          */
         App\Providers\AppServiceProvider::class,
         App\Providers\AuthServiceProvider::class,
         // App\Providers\BroadcastServiceProvider::class,
         App\Providers\EventServiceProvider::class,
         App\Providers\HorizonServiceProvider::class,
         App\Providers\PassportServiceProvider::class,
         App\Providers\RouteServiceProvider::class,
     ],
 
     /*
     |--------------------------------------------------------------------------
     | Class Aliases
     |--------------------------------------------------------------------------
     |
     | This array of class aliases will be registered when this application
     | is started. However, feel free to register as many as you wish as
     | the aliases are "lazy" loaded so they don't hinder performance.
     |
     */
 
     'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->toArray(),
 
     'headers' => [
         'csp' => env('APP_HEADER_CSP', ""),
         'xfo' => env('APP_HEADER_XFO', ""),
     ],
 
     // Locations of knowledge base articles
     'kb' => [
         // An article about suspended accounts
         'account_suspended' => env('KB_ACCOUNT_SUSPENDED'),
         // An article about a way to delete an owned account
         'account_delete' => env('KB_ACCOUNT_DELETE'),
         // An article about the payment system
         'payment_system' => env('KB_PAYMENT_SYSTEM'),
     ],
 
     'company' => [
         'name' => env('COMPANY_NAME'),
         'address' => env('COMPANY_ADDRESS'),
         'details' => env('COMPANY_DETAILS'),
         'email' => env('COMPANY_EMAIL'),
         'logo' => env('COMPANY_LOGO'),
         'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')),
         'copyright' => 'Apheleia IT AG',
     ],
 
     'storage' => [
         'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB
     ],
 
     'vat' => [
         'mode' => (int) env('VAT_MODE', 0),
     ],
 
     'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255',
 
     'payment' => [
         'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'),
         'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'),
     ],
 
     'with_ldap' => (bool) env('APP_LDAP', true),
     'with_imap' => (bool) env('APP_IMAP', false),
 
     'with_admin' => (bool) env('APP_WITH_ADMIN', false),
     'with_files' => (bool) env('APP_WITH_FILES', false),
     'with_reseller' => (bool) env('APP_WITH_RESELLER', false),
     'with_services' => (bool) env('APP_WITH_SERVICES', false),
     'with_signup' => (bool) env('APP_WITH_SIGNUP', true),
     'with_subscriptions' => (bool) env('APP_WITH_SUBSCRIPTIONS', true),
     'with_wallet' => (bool) env('APP_WITH_WALLET', true),
 
     'signup' => [
         'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0),
         'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0),
     ],
 
     'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')),
     'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')),
 
     'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')),
     'companion_download_link' => env(
         'COMPANION_DOWNLOAD_LINK',
         "https://mirror.apheleia-it.ch/pub/companion-app-beta.apk"
-    )
+    ),
+
+    'vpn' => [
+        'signing_key' => env('VPN_TOKEN_SIGNING_KEY', 0),
+    ],
 ];
diff --git a/src/routes/api.php b/src/routes/api.php
index 1d581198..6aeb6cb5 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,315 +1,317 @@
 <?php
 
 use App\Http\Controllers\API;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Route;
 
 /*
 |--------------------------------------------------------------------------
 | API Routes
 |--------------------------------------------------------------------------
 |
 | Here is where you can register API routes for your application. These
 | routes are loaded by the RouteServiceProvider within a group which
 | is assigned the "api" middleware group. Enjoy building your API!
 |
 */
 
 Route::group(
     [
         'middleware' => 'api',
         'prefix' => 'auth'
     ],
     function () {
         Route::post('login', [API\AuthController::class, 'login']);
 
         Route::group(
             ['middleware' => 'auth:api'],
             function () {
                 Route::get('info', [API\AuthController::class, 'info']);
                 Route::post('info', [API\AuthController::class, 'info']);
                 Route::get('location', [API\AuthController::class, 'location']);
                 Route::post('logout', [API\AuthController::class, 'logout']);
                 Route::post('refresh', [API\AuthController::class, 'refresh']);
             }
         );
     }
 );
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'middleware' => 'api',
         'prefix' => 'auth'
     ],
     function () {
         Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']);
 
         Route::post('password-reset/init', [API\PasswordResetController::class, 'init']);
         Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
         Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
     }
 );
 
 if (\config('app.with_signup')) {
     Route::group(
         [
             'domain' => \config('app.website_domain'),
             'middleware' => 'api',
             'prefix' => 'auth'
         ],
         function () {
             Route::get('signup/domains', [API\SignupController::class, 'domains']);
             Route::post('signup/init', [API\SignupController::class, 'init']);
             Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']);
             Route::get('signup/plans', [API\SignupController::class, 'plans']);
             Route::post('signup/validate', [API\SignupController::class, 'signupValidate']);
             Route::post('signup/verify', [API\SignupController::class, 'verify']);
             Route::post('signup', [API\SignupController::class, 'signup']);
         }
     );
 }
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'middleware' => ['auth:api', 'scope:mfa,api'],
         'prefix' => 'v4'
     ],
     function () {
         Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']);
         Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']);
         Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']);
         Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']);
 
         Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']);
     }
 );
 
 if (\config('app.with_files')) {
     Route::group(
         [
             'domain' => \config('app.website_domain'),
             'middleware' => ['auth:api', 'scope:fs,api'],
             'prefix' => 'v4'
         ],
         function () {
                 Route::apiResource('fs', API\V4\FsController::class);
                 Route::get('fs/{itemId}/permissions', [API\V4\FsController::class, 'getPermissions']);
                 Route::post('fs/{itemId}/permissions', [API\V4\FsController::class, 'createPermission']);
                 Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']);
                 Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']);
         }
     );
     Route::group(
         [
             'domain' => \config('app.website_domain'),
             'middleware' => [],
             'prefix' => 'v4'
         ],
         function () {
                 Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload'])
                     ->middleware(['api']);
                 Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download']);
         }
     );
 }
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'middleware' => ['auth:api', 'scope:api'],
         'prefix' => 'v4'
     ],
     function () {
         Route::apiResource('companions', API\V4\CompanionAppsController::class);
         // This must not be accessible with the 2fa token,
         // to prevent an attacker from pairing a new device with a stolen token.
         Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']);
 
         Route::apiResource('domains', API\V4\DomainsController::class);
         Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']);
         Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']);
         Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
         Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
 
         Route::apiResource('groups', API\V4\GroupsController::class);
         Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']);
         Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
         Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']);
 
         Route::apiResource('packages', API\V4\PackagesController::class);
 
         Route::apiResource('rooms', API\V4\RoomsController::class);
         Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']);
         Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']);
 
         Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom'])
             ->withoutMiddleware(['auth:api', 'scope:api']);
 
         Route::apiResource('resources', API\V4\ResourcesController::class);
         Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']);
         Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']);
         Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']);
 
         Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
         Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']);
         Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']);
         Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']);
 
         Route::apiResource('skus', API\V4\SkusController::class);
 
         Route::apiResource('users', API\V4\UsersController::class);
         Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']);
         Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
         Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
 
         Route::apiResource('wallets', API\V4\WalletsController::class);
         Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
         Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']);
         Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']);
 
         Route::get('password-policy', [API\PasswordPolicyController::class, 'index']);
         Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']);
         Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']);
 
         Route::post('payments', [API\V4\PaymentsController::class, 'store']);
         //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']);
         Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']);
         Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']);
         Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']);
         Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']);
         Route::post('payments/mandate/reset', [API\V4\PaymentsController::class, 'mandateReset']);
         Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']);
         Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']);
         Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']);
         Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']);
 
         Route::post('support/request', [API\V4\SupportController::class, 'request'])
             ->withoutMiddleware(['auth:api', 'scope:api'])
             ->middleware(['api']);
+
+        Route::get('vpn/token', [API\V4\VPNController::class, 'token']);
     }
 );
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'prefix' => 'webhooks'
     ],
     function () {
         Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']);
         Route::post('meet', [API\V4\MeetController::class, 'webhook']);
     }
 );
 
 if (\config('app.with_services')) {
     Route::group(
         [
             'domain' => \config('app.services_domain'),
             'prefix' => 'webhooks'
         ],
         function () {
             Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']);
             Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']);
             Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']);
             Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']);
             Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']);
             Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']);
             Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']);
         }
     );
 }
 
 if (\config('app.with_admin')) {
     Route::group(
         [
             'domain' => 'admin.' . \config('app.website_domain'),
             'middleware' => ['auth:api', 'admin'],
             'prefix' => 'v4',
         ],
         function () {
             Route::apiResource('domains', API\V4\Admin\DomainsController::class);
             Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']);
             Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']);
             Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']);
 
             Route::apiResource('groups', API\V4\Admin\GroupsController::class);
             Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']);
             Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']);
 
             Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
             Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class);
             Route::apiResource('skus', API\V4\Admin\SkusController::class);
 
             Route::apiResource('users', API\V4\Admin\UsersController::class);
             Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
             Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']);
             Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']);
             Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']);
             Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']);
             Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
             Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']);
             Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']);
 
             Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
             Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']);
             Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']);
 
             Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']);
         }
     );
 }
 
 if (\config('app.with_reseller')) {
     Route::group(
         [
             'domain' => 'reseller.' . \config('app.website_domain'),
             'middleware' => ['auth:api', 'reseller'],
             'prefix' => 'v4',
         ],
         function () {
             Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
             Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']);
             Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']);
             Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']);
 
             Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
             Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']);
             Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']);
 
             Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
             Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']);
 
             Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']);
             Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']);
             Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']);
             Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']);
             Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']);
             Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']);
             Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']);
             Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']);
 
             Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
             Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class);
             Route::apiResource('skus', API\V4\Reseller\SkusController::class);
 
             Route::apiResource('users', API\V4\Reseller\UsersController::class);
             Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
             Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']);
             Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']);
             Route::post('users/{id}/resync', [API\V4\Reseller\UsersController::class, 'resync']);
             Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']);
             Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
             Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']);
             Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']);
 
             Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
             Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']);
             Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']);
             Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']);
             Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']);
 
             Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']);
         }
     );
 }
diff --git a/src/tests/Feature/Controller/VPNTest.php b/src/tests/Feature/Controller/VPNTest.php
new file mode 100644
index 00000000..10b67a7c
--- /dev/null
+++ b/src/tests/Feature/Controller/VPNTest.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use Tests\TestCase;
+use Carbon\Carbon;
+use Lcobucci\JWT\Encoding\JoseEncoder;
+use Lcobucci\JWT\Token\Parser;
+use Lcobucci\JWT\Token\RegisteredClaims;
+use Lcobucci\JWT\Signer\Rsa\Sha256;
+use Lcobucci\JWT\Signer\Key\InMemory;
+use Lcobucci\JWT\Validation\Constraint;
+use Lcobucci\JWT\Validation\Validator;
+use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
+
+class VPNTest extends TestCase
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        Carbon::setTestNow(Carbon::create(2022, 02, 02, 13, 0, 0));
+        parent::setUp();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function tearDown(): void
+    {
+        parent::tearDown();
+    }
+
+    /**
+     * Test the webhook
+     */
+    public function testToken(): void
+    {
+        // openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
+        $privateKey = <<<EOF
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmfUb5J+na6LV1
+6PTmOX6alvyEbWEUA1HykKFsKRq3q8nraZU4LlfhM1qoeA23re4WEmT2SoCXq8sd
+ewRXP+swYl6vAPZFtqCR52vApEXn+b221g3gEa5KnZeQ3APTej6wcs+3etMcDFAh
+Sabrjvl3eoHXmO2RQoeSjSfp6N4LPvbbOjkLR5EYdDNxVuwU7HRkwkXve1rWh94z
+RbSl7o4XkP/qzOGEDfrm7SivcSWqwFndHkxNeum1FnBfDZ9ChOwEunuIZiPX2TDb
+K3WZ+TJn29xNERAJJgFZLspB9RK+FT/lCX4IZgdl8ZyU8lC68QXaZuAcQhg7TU7l
+xBw7ZJXBAgMBAAECggEAAqEL/R9tJmISNYJgKv+MQ9mFCNFSXQwgCpN6nRg5aLOb
+h25o0b+M7zc4pAbrTokpBJZgm5wPE6C99HakGahJKOqGF2o4OV8aLNupnFvrvQSj
+reQLnuF17g3hiMMqA02qTkPYiwud6FLlyV9zSu58etoc0UsEBg0gfMcNCDj/bVAt
+b+JvZjfFXG9TQRNx7F3H/yRO5kHw+oqeDBAvHN+Mkq9KATLQ8GBZznwpN7fHL+Is
+PZCDvcAg/4OtN1cNgMOKkdBDSMzjtn7xHUX9jl03VUPvXfa9KDdFCMdVndtnACRK
+lFNMdD+ugQ7Ch1oHI16HUWo663fjF+9m3neYjoAiwQKBgQC+UykotVMHX65OsYx7
+tXankRmj9WnoiUFrfClrdVynbkcbZvCt9NdNluv0eNRbEaNXD07nOIppACSDaQ0i
+DdIALbFU6I3kzAF+z25Rh6SaIl5gaUGxSLSzmpswJsv0hiu/5Sb8t6fOX+VWBV7I
+Fw7MqASoggV5rEAvRVtwiw4TsQKBgQDf8IwZDwXlTFc7/VsAjLfb+edpVcavOB9d
+DAXAwUH+2FJb9AWPOLrTnKdsRV9yw9FueMciuROGi9btOYxEcLGvNcvsyaWy0eFQ
+vN2w0NsxpBodRylxMQLGDc5wO9lbrVuftzC0rDaQaD9gPQaKFoQI7ww81Jmx6u1Y
+OP/Nsq53EQKBgD8vIoHmOItBI3/yh53mL18P18BLz/4n2vURAjsvejQHc0nQkeRe
+XT/f87N0jaMyJtTXOy2d4q1bI8QQkxCUH/x5Lt7uWXT0mSZ9PLWKX4XgFQ7SwsFV
+TtA1aoHAz4L9K/cH3zqUyfvEcEFvhPjOVtZwjSNYDvNG0QQgdWvWbjTxAoGAE50R
+6C/0qDyjd1GdYtLwV4fvyL4GhNo5hQDEkDlc+mEf9YXN5tllI5uY3lbFIVwdP7u8
+VUI4f5RH4scjjesA5QOlNLwEk0DmpxejoxTn3dUtpFrTOmK8h3Q2HIZhZzIr0DVP
+QsPCk6tNwbQWmomWTuIBBGLqgza8SvnTDcUUmsECgYBXk4MAk1FennJuDvpiD1Gg
+HiIQykUOQeA5wk+R97X7D4kI8kj6XzCuRjG+nSJiYmpZBHRvA5XtRtMJWep11R6O
+8yu8ftj4xBQr6roUoHwJ/JBxe8JuKW3yh52CaZLP2KjizwzNI0hDMUzinZIReex+
+iFyt8WwtMwzW/520PlwUyQ==
+-----END PRIVATE KEY-----
+EOF;
+
+        // openssl rsa -pubout -in private.pem -out public.pem
+        $publicKey = <<<EOF
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApn1G+Sfp2ui1dej05jl+
+mpb8hG1hFANR8pChbCkat6vJ62mVOC5X4TNaqHgNt63uFhJk9kqAl6vLHXsEVz/r
+MGJerwD2RbagkedrwKRF5/m9ttYN4BGuSp2XkNwD03o+sHLPt3rTHAxQIUmm6475
+d3qB15jtkUKHko0n6ejeCz722zo5C0eRGHQzcVbsFOx0ZMJF73ta1ofeM0W0pe6O
+F5D/6szhhA365u0or3ElqsBZ3R5MTXrptRZwXw2fQoTsBLp7iGYj19kw2yt1mfky
+Z9vcTREQCSYBWS7KQfUSvhU/5Ql+CGYHZfGclPJQuvEF2mbgHEIYO01O5cQcO2SV
+wQIDAQAB
+-----END PUBLIC KEY-----
+EOF;
+
+        \config(['app.vpn.token_signing_key' => $privateKey]);
+
+        $john = $this->getTestUser('john@kolab.org');
+
+        $response = $this->get("api/v4/vpn/token");
+        $response->assertStatus(401);
+
+        $response = $this->actingAs($john)->get("api/v4/vpn/token");
+        $response->assertStatus(200);
+
+        $json = $response->json();
+        $jwt = $json['token'];
+
+        $parser = new Parser(new JoseEncoder());
+
+        /** @var \Lcobucci\JWT\UnencryptedToken $token */
+        $token = $parser->parse($jwt);
+
+        $this->assertSame("default", $token->claims()->get('entitlement'));
+        $this->assertSame("2022-02-02T13:00:00+00:00", $token->claims()->get(
+            RegisteredClaims::ISSUED_AT
+        )->format(\DateTimeImmutable::RFC3339));
+        $this->assertSame(0, Carbon::now()->diffInSeconds(new Carbon($token->claims()->get(
+            RegisteredClaims::ISSUED_AT
+        ))));
+
+        $validator = new Validator();
+        $key = InMemory::plainText($publicKey);
+        $validator->assert($token, new Constraint\SignedWith(new Sha256(), $key));
+
+        $invalidKey = <<<EOF
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApn1G+Sfp2ui1dej05jl+
+mpb8hG1hFANR8pChbCkat6vJ62mVOC5X4TNaqHgNt63uFhJk9kqAl6vLHXsEVz/r
+MGJerwD2RbagkedrwKRF5/m9ttYN4BGuSp2XkNwD03o+sHLPt3rTHAxQIUmm6475
+d3qB15jtkUKHko0n6ejeCz722zo5C0eRGHQzcVbsFOx0ZMJF73ta1ofeM0W0pe6O
+F5D/6szhhA365u0or3ElqsBZ3R5MTXrptRZwXw2fQoTsBLp7iGYj19kw2yt1mfky
+Z9vcTREQCSYBWS7KQfUSvhU/5Ql+CGYHZfGclPJQuvEF2mbgHEIYO01O5cQcO2SV
+wQIDAQAC
+-----END PUBLIC KEY-----
+EOF;
+        $this->expectException(RequiredConstraintsViolated::class);
+        $key = InMemory::plainText($invalidKey);
+        $validator->assert($token, new Constraint\SignedWith(new Sha256(), $key));
+    }
+}