-
Notifications
You must be signed in to change notification settings - Fork 0
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.
All dashboard sessions use JWTs signed with an RSA private key and verified with the corresponding public key (RS256 algorithm).
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
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
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 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:
- A random key pair is generated using
crypto.randomUUID()and cryptographic functions - The secret key is immediately hashed with bcrypt (12 salt rounds)
- Only the bcrypt hash (
skHash) is stored in the database - 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.
Secrets stored in the tmam Vault are encrypted before being written to the database. Two encryption schemes are supported:
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.
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. ChangingEDCRYPT_PASSorPBECRYPT_PASSwill make all existing vault entries unreadable.
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.
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" });
}
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.
- Replace the default RSA key pair with your own (see above)
- Set strong, unique values for
EDCRYPT_PASSandPBECRYPT_PASS - Use a strong
NEXTAUTH_SECRET(openssl rand -base64 32) - Restrict
ORIGIN_ALLOW_LISTto your actual dashboard domain - Configure HTTPS via Nginx (see Self-Hosting)
- Use a hosted MongoDB with authentication enabled for production
- Set
NODE_ENV=production
tmam is built with security as a first-class concern. This page documents the authentication, encryption, and access control mechanisms used throughout the platform.
All dashboard sessions use JWTs signed with an RSA private key and verified with the corresponding public key (RS256 algorithm).
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
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
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.pemPaste the output (starting with -----BEGIN RSA PRIVATE KEY-----\n...) into JWT_PRIVATE_SECRET and JWT_PUBLIC_SECRET in server/src/.env.
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:
- A random key pair is generated using
crypto.randomUUID()and cryptographic functions - The secret key is immediately hashed with bcrypt (12 salt rounds)
- Only the bcrypt hash (
skHash) is stored in the database - 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.
Secrets stored in the tmam Vault are encrypted before being written to the database. Two encryption schemes are supported:
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.
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. ChangingEDCRYPT_PASSorPBECRYPT_PASSwill make all existing vault entries unreadable.
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.
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" });
}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-TypeThe server also uses helmet for HTTP security headers.
- Replace the default RSA key pair with your own (see above)
- Set strong, unique values for
EDCRYPT_PASSandPBECRYPT_PASS - Use a strong
NEXTAUTH_SECRET(openssl rand -base64 32) - Restrict
ORIGIN_ALLOW_LISTto 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