Auth is the part of any app where cutting corners has the worst consequences. Here's every security decision made in Knotos, and the reasoning behind each one.
Password hashing with bcrypt
Knotos hashes passwords with bcryptjs at cost factor 12. Cost 12 means ~300ms per hash on a modern server — slow enough to make brute-force expensive, fast enough to be imperceptible to the user logging in.
// Registration
const passwordHash = await bcrypt.hash(password, 12);
await userModel.create({ phone, displayName, passwordHash });
// Login
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) throw new UnauthorizedException('Incorrect password');bcrypt also handles salt generation internally — every hash is unique even for identical passwords, which defeats rainbow table attacks.
JWT structure and TTL
After successful login, Knotos issues a JWT signed with a 512-bit secret (generated with openssl rand -base64 64). The token contains:
sub— MongoDB ObjectId of the useriat/exp— issued-at and expiry (7 days)- No sensitive fields — the token is signed but not encrypted.
Seven days is long enough to avoid constant re-logins on mobile, short enough to limit exposure if a token is stolen. Refresh tokens are on the roadmap.
Storing the token in iOS Keychain
Early versions of Knotos stored the JWT in UserDefaults — quick to implement, but UserDefaults is not encrypted and is accessible if a device is jailbroken. Keychain items are encrypted by the OS and tied to the app's bundle identifier.
enum KeychainHelper {
private static let service = "vanthuongdao.knotos.app"
static func save(key: String, value: String) {
let data = value.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
]
SecItemDelete(query as CFDictionary) // overwrite if exists
SecItemAdd(query as CFDictionary, nil)
}
}A one-time migration runs on app launch to move any existing UserDefaults token into Keychain and delete the insecure copy.
Rate limiting on auth endpoints
Without rate limiting, an attacker can brute-force passwords at thousands of attempts per minute. Knotos uses @nestjs/throttler:
- Global: 60 requests / 60 seconds per IP (prevents general abuse)
- Auth endpoints: 5 requests / 60 seconds (stops password brute-force cold)
// Tight limit on AuthController
@Throttle({ default: { ttl: 60000, limit: 5 } })
@Controller('auth')
export class AuthController { ... }CORS in production
In development, CORS is open. In production, allowed origins are set via the CORS_ORIGINS environment variable — a comma-separated list of domains. This prevents any other website from making credentialed requests to the API.
