A multi-tenant SaaS platform for secure license and session management.
This is a side project of mine in which I took my previous license authentication service and recreated it with multi-tenancy architecture. Original backend (no frontend) may be found here. Contributions and pull requests are greatly appreciated!
Below is information regarding the development aspect of this project, specifically prerequisites, building and running the codebase, and contributing to it.
The web application runs on Node.js version 22.
Install the dependencies inside the website directory with:
npm installThe server and migration utilities are written in C# and target .NET 10.0 (see global.json).
The execution of the application stack in containers will require installation of the Docker Engine with the Docker Compose plugin. It will require PostgreSQL and Redis, both of which are provided in the [Docker Compose configuration.
-
Create a
.envfile using the template file.env.example:cp .env.example .env
-
Start everything up using Docker:
docker compose up --build
For the initial run, building will be required, which can take a while. This launches the PostgreSQL, Redis, key generator, database migration tool, API server, NGINX, and website itself.
Note
After applying database migrations once and exiting, the migration service ends execution with the following message: migration_utility_container ... Exited (0) which is an expected outcome, indicating success and not crash.
Once running, the stack is reachable at:
| Service | URL |
|---|---|
| API server (direct) | http://localhost:8080 |
| Web app / API via NGINX | http://localhost |
| Web app (dev, direct) | http://localhost:3000 |
| PostgreSQL | localhost:5432 (user postgres, database auth) |
Use docker compose up -d to run in the background, and docker compose down to stop the stack. Add -v to also wipe the database volume and start completely fresh.
A bootstrap admin tenant is created via database migrations for an out-of-the-box first login with:
- Email:
admin@authio.com - Password:
admin123
Note that this is the owner of the seeded test team.
Warning
Change the password or remove this account before deploying to production.
Web app is located in the website folder and uses Next.js along with React and Tailwind CSS.
The web app includes several scripts for linting, building, and running the project. To list all available scripts execute the following command from the website folder:
npm runIn the Docker Compose stack, the web app works in the development mode (Dockerfile.dev) with hot reload and consumes the API via the NEXT_PUBLIC_API_URL environment variable.
Server application is located in the Server folder. It is an ASP.NET Core application and it provides the REST API which is consumed by the web app.
The server requires a PostgreSQL database for persistent data and Redis for session state. Both are available as services in the Docker Compose file. The keys used by the server are generated by the keygen service and shared through a Docker volume.
Configuration of the server happens through environment variables (see .env.example). The most important among them are DATABASE_URL, DATABASE_LOGGER_URL, and REDIS_URL.
Database migrations are located under the MigrationUtility/Database/Scripts folder. The migration utility is an independent .NET tool that applies the migrations and exits. It is executed automatically as part of the migration service from the Docker Compose configuration, but can also be run manually against any PostgreSQL database by specifying DATABASE_URL.
Should you choose to use the external PostgreSQL database for your Authio deployment, run the following SQL queries to configure the activity logger.
Warning
Don't forget to remove hardcoded credentials from production systems and set up the admin password after executing the migration.
CREATE USER authio_serilog WITH PASSWORD 'authio.24';
CREATE DATABASE activity_logs;
GRANT ALL PRIVILEGES ON DATABASE activity_logs TO authio_serilog;
\c activity_logs
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO authio_serilog;
GRANT USAGE, CREATE ON SCHEMA public TO authio_serilog;Warning
Be sure to replace hardcoded credentials in production environments and set up the admin password after running the migration.
This section describes the mechanism used by the original single-tenant service to protect communications between a licensed client application (e.g., a game trainer or a desktop app) and the API endpoint. It is symmetric authenticated encryption: both client and server know the same secret key and use it to encrypt and authenticate every request/response body. No entity on the network connection, including anyone who has decrypted the TLS connection using a proxy, will be able to read or modify the payload without the key.
HTTPS ensures the transport security. No more work happens once the request is out of the TLS stack: a customer can attach a debugger to their machine, set a breakpoint in the HTTP client code, and inspect or modify the plaintext that is going to be sent by the application. This is the entirety of threat model for the licensing system, where the attacker controls the client. The second, application-level symmetric encryption layer ensures that the payload bytes (the license key, the HWID, the session token) are ciphertext before even being written to the socket and the server rejects everything that does not verify.
Encryption is done using the ChaCha20-Poly1305 scheme, which is an AEAD (Authenticated Encryption with Associated Data) mode implemented via NSec.Cryptography library (NSec.Cryptography package, still used in Server/Server.csproj):
- ChaCha20 is the stream cipher responsible for the encryption.
- Poly1305 is the one-time authenticator generating a 16-byte tag; on decryption the tag is verified, and any single flipped bit renders the entire payload invalid.
- So, one AEAD mode provides both confidentiality and integrity in one operation.
Keys are 256-bit (32 bytes). They are generated only once in keygen sidecar
container on the first startup (keygen/entrypoint.sh) and saved to the shared secrets volume:
| Secret file | Env var | Purpose |
|---|---|---|
/secrets/Chacha20 |
CHACHA |
Symmetric key for payload encrypt/decrypt |
/secrets/symmetricKey |
SYM_KEY |
HS256 key used to sign session-token JWTs |
Server/HostedServices/EnvironmentVariableService.cs reads these keys into the process environment. In the multi-tenant version each application also has its own ClientDecryptionChaChaKey (Server/Models/Entities/Application.cs), so the payload key is scoped to the application instead of one key for all.
ChaCha20-Poly1305 requires 96-bit (12 bytes) nonce. Nonce+key combination cannot repeat! The reuse discloses the keystream and breaks Poly1305 authentication. Each message contains a nonce, which is a randomly generated number. The nonce is prepended to the ciphertext so that the recipient can remove it before decryption.
┌────────────┬──────────────────────────────┬─────────────┐
│ nonce (12) │ ciphertext (len = plaintext) │ tag (16) │
└────────────┴──────────────────────────────┴─────────────┘
└──────────── all base64url-encoded on the wire ────┘
License keys also use base64url without padding encoding, implemented in Guider (Server/Services/Guider.cs): 16 bytes of GUID are encoded into 22 characters with +/ replaced by -_.
Before any encrypted call, the client must hold the application's ChaCha key. It is handed out once, at integration time from the dashboard, never embedded in a build in plaintext and never sent in an encrypted body (chicken-and-egg).
sequenceDiagram
actor Dev as Tenant (dashboard)
participant API as Authio API
participant DB as Database
participant App as Client app build
Dev->>API: Create application
API->>DB: store app + ClientDecryptionChaChaKey (32 bytes)
API-->>Dev: app id + ChaCha key (shown once)
Dev->>App: embed key in the obfuscated client
Note over App: key lives only in the compiled client,<br/>never in a plaintext request
After obtaining the key, all calls made will be encrypted before sending and decrypted after receiving. The session token (generated on log-in) is located inside the encrypted body and therefore not exposed regardless of TLS stripping.
sequenceDiagram
participant App as Client app
participant MW as Server crypto middleware
participant EP as Endpoint handler
Note over App: build JSON payload<br/>{ licenseKey, hwid, sessionToken, ... }
App->>App: nonce = random(12)
App->>App: sealed = ChaCha20Poly1305.Encrypt(key, nonce, json)
App->>MW: POST /auth/license/login<br/>body = base64url(nonce ‖ sealed ‖ tag)
MW->>MW: split nonce / ciphertext / tag
MW->>MW: plaintext = Decrypt(key, nonce, ciphertext, tag)
alt tag invalid
MW-->>App: 400 (tampered / wrong key)
else tag valid
MW->>EP: hand decrypted JSON to handler
EP->>EP: validate license, create session
EP-->>MW: response object
MW->>MW: nonce2 = random(12)
MW->>MW: sealed2 = Encrypt(key, nonce2, response)
MW-->>App: base64url(nonce2 ‖ sealed2 ‖ tag2)
end
App->>App: Decrypt(key, nonce2, ...) → plaintext response
The cryptographic layer encloses the handler: it decrypts the body prior to model binding and encrypts the body after handler execution, such that only plain DTOs can be received.
flowchart LR
A[HTTPS / NGINX] --> B[decrypt middleware]
B -->|plaintext JSON| C[FastEndpoints handler]
C -->|plaintext result| D[encrypt middleware]
D --> A
B -. tag fails .-> E[["400 reject"]]
Encrypted payloads protect the content of an HTTP request; certificate pinning is responsible for protecting who the client connects to. In order to access the auth API, any binary must require to:
- Enable TLS for all connections. HTTP requests will be blocked out-of-the-box without allowing to fallback to
http://protocol, to be redirected to insecure locations or to proceed after TLS handshake error. - Implement certificate pinning. A hard-coded certificate of the auth server (or the public key/SPKI) is embedded in the application and the application ensures that the server has a pinned certificate. If not, the connection will be refused by the client before passing any payload data, be it encrypted or plain-text.
Why is this important for the license manager? As we now know the attacker has the control over the machine, the next step after failed extraction of the cipher will be man-in-the-middle attack where one installs a root CA locally (Fiddler, mitmproxy or Charles), redirects the application to the faked authentication endpoint and tries to replay or forge authentication responses. A trusted root CA will bypass all TLS validation as OS "trusts" the certificates of the proxy. Pinning totally ignores OS trust store and uses only one embedded certificate, which means that even "valid" MITM certificates will fail verification and will block any communications between client and server. Combined with the ChaCha payload layer this approach will prevent any traffic interception or substitution of server and client's connection to it.
flowchart TD
A[Client starts request] --> B{Scheme == https?}
B -- no --> X[["Abort: TLS required"]]
B -- yes --> C[TLS handshake]
C --> D{Presented cert == pinned cert?}
D -- no --> Y[["Abort: pin mismatch - likely MITM"]]
D -- yes --> E[Send ChaCha-sealed payload]
E --> F[Receive + decrypt response]
The operational overhead associated with the use of pinning is that in case of rotating the certificate of the server, all clients that are pinned to the old certificate would fail to work. This issue should be addressed via pinning of public key / SPKI (it does not change even after the certificate reissuance with the same key) or by offering a backup pinset.
| Attack | Mitigation |
|---|---|
| Steal secrets after TLS termination | Body is encrypted before reaching the network socket |
| Modify a field (for instance, modify expiry) | Poly1305 tag validation fails → request is rejected |
| Replay a captured request | New nonce for each message; use session/timestamping |
| Obtain the key from traffic | Key is never transferred and provided out of band |
| MITM attack with a local root CA | Certificate pinning does not use OS trust store |
| Downgrade/redirect to HTTP | Client requires HTTPS and denies any other connections |
Note
It is the legacy client↔server payload schema. In the current multi-tenant implementation, the ChaCha key is provisioned per application (ClientDecryptionChaChaKey) but the encryption/decryption middleware is not implemented yet.
In order to run it in a VM, you need to upload the project to your VPS and configure the provided files in the releases. The Deployment folder includes the production docker-compose.yml and NGINX config. The certbot folder includes Let's Encrypt certificates for NGINX's HTTPS.
- the main branch is the most up-to-date
- for the development of a new feature or bugfix, a new branch should be created from the main branch
- Features and bugs should exist as a GitHub issue with an appropriate description
- To merge code into the main branch, a pull request should be opened with a description of the change