Skip to content

Security Architecture

Niccanor Dhas edited this page Feb 22, 2026 · 1 revision

Security Architecture

tmam is built with security as a first-class concern. This page documents the authentication, encryption, and access control mechanisms used throughout the platform.


JWT Authentication (RSA-256)

All dashboard sessions use JWTs signed with an RSA private key and verified with the corresponding public key (RS256 algorithm).

Token lifecycle

Login → Server signs JWT with RSA private key
      → Token stored in database (alongside user record)
      → Client sends token in Authorization: Bearer header
      → Server verifies:
          1. Token exists in database (not revoked)
          2. JWT signature valid against RSA public key
          3. User ID + Role ID in token matches database record
      → Access granted or 401 Unauthorized

Why database cross-validation?

A standard JWT is stateless and valid until expiry. tmam adds a server-side token registry — every token is stored in the database with the user it was issued to. This means:

  • Logout is instant and effective — signing out deletes the token from the database; any subsequent request with that token is rejected regardless of JWT expiry
  • No need to wait for token expiry to revoke compromised sessions
  • Token hijacking is mitigated — a stolen token from a logged-out user is immediately invalid

Generating production RSA keys

The repo ships with a pre-generated key pair for local development. For production, generate your own:

# 1. Generate 2048-bit RSA private key
openssl genrsa -out private.pem 2048

2. Extract the public key

openssl rsa -in private.pem -pubout -out public.pem

3. Convert to single-line format for .env

Linux/macOS:

awk 'NF {sub(/\r/, ""); printf "%s\n",$0;}' private.pem awk 'NF {sub(/\r/, ""); printf "%s\n",$0;}' public.pem

Paste the output (starting with -----BEGIN RSA PRIVATE KEY-----\n...) into JWT_PRIVATE_SECRET and JWT_PUBLIC_SECRET in server/src/.env.


SDK Key Authentication

SDK connections use a public/secret key pair instead of JWTs.

Key Format Usage
Public Key pk-tmam-xxxxxxxx Sent as X-Public-Key header
Secret Key sk-tmam-xxxxxxxx Sent as X-Secret-Key header

The secret key is never stored in plaintext. On creation:

  1. A random key pair is generated using crypto.randomUUID() and cryptographic functions
  2. The secret key is immediately hashed with bcrypt (12 salt rounds)
  3. Only the bcrypt hash (skHash) is stored in the database
  4. A short preview (skShort) is stored for display purposes only

At authentication time, the server retrieves the record by public key, then calls bcrypt.compare(incomingSecret, storedHash). The plaintext secret is never reconstructed.

⚠️ The secret key is shown only once — at the moment of creation in the dashboard. Store it securely. If lost, delete the key and create a new one.


Encryption at Rest (Vault)

Secrets stored in the tmam Vault are encrypted before being written to the database. Two encryption schemes are supported:

IV-Based (AES-192-CBC)

Used for general secret storage:

Input:  plaintext secret
Key:    crypto.scryptSync(EDCRYPT_PASS, 'salt', 24)   ← 24 bytes for AES-192
IV:     random 16-byte buffer (stored with the record)
Output: hex-encoded ciphertext

Configured via EDCRYPT_ALGORITHM=aes-192-cbc and EDCRYPT_PASS in server/src/.env.

PBE (Password-Based Encryption, AES-256-CBC)

Used for specific internal use cases:

Input:  plaintext secret
Key:    crypto.pbkdf2Sync(PBECRYPT_PASS, PBE_IV, 10000, 32, 'sha256')
IV:     PBE_IV (hex, from .env)
Output: hex-encoded ciphertext

Configured via PBECRYPT_PASS and PBE_IV in server/src/.env.

⚠️ Do not lose or change your encryption passwords after secrets have been written. Changing EDCRYPT_PASS or PBECRYPT_PASS will make all existing vault entries unreadable.


Password Hashing

User passwords are hashed with bcrypt before storage:

bcrypt.hash(password, saltRounds)   ← saltRounds default: 12

SALT_ROUNDS is configurable in server/src/.env. Higher values increase security but slow down login.


Role-Based Access Control (RBAC)

Every API endpoint is protected by a middleware chain that runs in sequence:

Request
  → UserAgent.verify          (validate request origin)
  → UserAccess.clientOnly     (ensure it's a dashboard client, not SDK)
  → VerifyToken.user          (validate JWT)
  → VerifyAccount.isLoggedIn  (check active session)
  → VerifyAccount.isAccountVerified  (check email confirmed)
  → [Controller logic]

Within controllers, a permission() helper checks whether the authenticated user has full or limited access to the requested organization/project:

const access = await new MemberController().permission(orgId, userId);

if (access === 'limited') { return payload.error({ code: 403, msg: "Insufficient permissions" }); }


Request Origin Validation

The server validates the User-Agent header on all requests using UserAgent.verify middleware, and restricts dashboard API access to client-type requests via UserAccess.clientOnly. SDK routes use UserAccess.sdkOnly instead.

CORS is configured in server/src/.env:

ORIGIN_ALLOW_LIST=http://web-client:3001
CORS_METHODS_EXPRESS=GET,PATCH,POST,DELETE
WHITELIST_HEADERS=Authorization,Content-Type

The server also uses helmet for HTTP security headers.


Security Checklist for Production

  • Replace the default RSA key pair with your own (see above)
  • Set strong, unique values for EDCRYPT_PASS and PBECRYPT_PASS
  • Use a strong NEXTAUTH_SECRET (openssl rand -base64 32)
  • Restrict ORIGIN_ALLOW_LIST to your actual dashboard domain
  • Configure HTTPS via Nginx (see Self-Hosting)
  • Use a hosted MongoDB with authentication enabled for production
  • Set NODE_ENV=production
# Security Architecture

tmam is built with security as a first-class concern. This page documents the authentication, encryption, and access control mechanisms used throughout the platform.


JWT Authentication (RSA-256)

All dashboard sessions use JWTs signed with an RSA private key and verified with the corresponding public key (RS256 algorithm).

Token lifecycle

Login → Server signs JWT with RSA private key
      → Token stored in database (alongside user record)
      → Client sends token in Authorization: Bearer header
      → Server verifies:
          1. Token exists in database (not revoked)
          2. JWT signature valid against RSA public key
          3. User ID + Role ID in token matches database record
      → Access granted or 401 Unauthorized

Why database cross-validation?

A standard JWT is stateless and valid until expiry. tmam adds a server-side token registry — every token is stored in the database with the user it was issued to. This means:

  • Logout is instant and effective — signing out deletes the token from the database; any subsequent request with that token is rejected regardless of JWT expiry
  • No need to wait for token expiry to revoke compromised sessions
  • Token hijacking is mitigated — a stolen token from a logged-out user is immediately invalid

Generating production RSA keys

The repo ships with a pre-generated key pair for local development. For production, generate your own:

# 1. Generate 2048-bit RSA private key
openssl genrsa -out private.pem 2048

# 2. Extract the public key
openssl rsa -in private.pem -pubout -out public.pem

# 3. Convert to single-line format for .env
# Linux/macOS:
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem

Paste the output (starting with -----BEGIN RSA PRIVATE KEY-----\n...) into JWT_PRIVATE_SECRET and JWT_PUBLIC_SECRET in server/src/.env.


SDK Key Authentication

SDK connections use a public/secret key pair instead of JWTs.

Key Format Usage
Public Key pk-tmam-xxxxxxxx Sent as X-Public-Key header
Secret Key sk-tmam-xxxxxxxx Sent as X-Secret-Key header

The secret key is never stored in plaintext. On creation:

  1. A random key pair is generated using crypto.randomUUID() and cryptographic functions
  2. The secret key is immediately hashed with bcrypt (12 salt rounds)
  3. Only the bcrypt hash (skHash) is stored in the database
  4. A short preview (skShort) is stored for display purposes only

At authentication time, the server retrieves the record by public key, then calls bcrypt.compare(incomingSecret, storedHash). The plaintext secret is never reconstructed.

⚠️ The secret key is shown only once — at the moment of creation in the dashboard. Store it securely. If lost, delete the key and create a new one.


Encryption at Rest (Vault)

Secrets stored in the tmam Vault are encrypted before being written to the database. Two encryption schemes are supported:

IV-Based (AES-192-CBC)

Used for general secret storage:

Input:  plaintext secret
Key:    crypto.scryptSync(EDCRYPT_PASS, 'salt', 24)   ← 24 bytes for AES-192
IV:     random 16-byte buffer (stored with the record)
Output: hex-encoded ciphertext

Configured via EDCRYPT_ALGORITHM=aes-192-cbc and EDCRYPT_PASS in server/src/.env.

PBE (Password-Based Encryption, AES-256-CBC)

Used for specific internal use cases:

Input:  plaintext secret
Key:    crypto.pbkdf2Sync(PBECRYPT_PASS, PBE_IV, 10000, 32, 'sha256')
IV:     PBE_IV (hex, from .env)
Output: hex-encoded ciphertext

Configured via PBECRYPT_PASS and PBE_IV in server/src/.env.

⚠️ Do not lose or change your encryption passwords after secrets have been written. Changing EDCRYPT_PASS or PBECRYPT_PASS will make all existing vault entries unreadable.


Password Hashing

User passwords are hashed with bcrypt before storage:

bcrypt.hash(password, saltRounds)   ← saltRounds default: 12

SALT_ROUNDS is configurable in server/src/.env. Higher values increase security but slow down login.


Role-Based Access Control (RBAC)

Every API endpoint is protected by a middleware chain that runs in sequence:

Request
  → UserAgent.verify          (validate request origin)
  → UserAccess.clientOnly     (ensure it's a dashboard client, not SDK)
  → VerifyToken.user          (validate JWT)
  → VerifyAccount.isLoggedIn  (check active session)
  → VerifyAccount.isAccountVerified  (check email confirmed)
  → [Controller logic]

Within controllers, a permission() helper checks whether the authenticated user has full or limited access to the requested organization/project:

const access = await new MemberController().permission(orgId, userId);

if (access === 'limited') {
    return payload.error({ code: 403, msg: "Insufficient permissions" });
}

Request Origin Validation

The server validates the User-Agent header on all requests using UserAgent.verify middleware, and restricts dashboard API access to client-type requests via UserAccess.clientOnly. SDK routes use UserAccess.sdkOnly instead.

CORS is configured in server/src/.env:

ORIGIN_ALLOW_LIST=http://web-client:3001
CORS_METHODS_EXPRESS=GET,PATCH,POST,DELETE
WHITELIST_HEADERS=Authorization,Content-Type

The server also uses helmet for HTTP security headers.


Security Checklist for Production

  • Replace the default RSA key pair with your own (see above)
  • Set strong, unique values for EDCRYPT_PASS and PBECRYPT_PASS
  • Use a strong NEXTAUTH_SECRET (openssl rand -base64 32)
  • Restrict ORIGIN_ALLOW_LIST to your actual dashboard domain
  • Configure HTTPS via Nginx (see [Self-Hosting](Self-Hosting-with-Docker#enabling-nginx-for-production))
  • Use a hosted MongoDB with authentication enabled for production
  • Set NODE_ENV=production

Clone this wiki locally