Stay Sidekick applies multiple independent security layers so that a weakness in one does not compromise the entire system. Authentication, request integrity, password storage, rate limiting, transport, encrypted storage, and GDPR compliance are all addressed at separate implementation points across the stack. This page documents each layer with its implementation location so you know exactly where to look when auditing or extending the platform.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/sdurutr436/stay-sidekick/llms.txt
Use this file to discover all available pages before exploring further.
Security controls at a glance
| Layer | Mechanism | Implementation |
|---|---|---|
| Authentication | JWT HS256, configurable TTL | app/auth/routes.py, app/security/jwt.py |
| Request integrity | CSRF Double-Submit Cookie | app/security/csrf.py |
| Password storage | BCrypt with random salt | app/auth/passwords.py |
| Rate limiting | Flask-Limiter, per-IP | app/extensions.py, individual blueprints |
| Cross-origin access | CORS allow-list | App factory |
| Credential storage | Fernet symmetric encryption | app/common/crypto.py |
| Guest data | In-memory only, never persisted | All operational modules |
| Public forms | Cloudflare Turnstile + honeypot | app/contact/, app/security/ |
| Security headers | Nginx (HSTS, CSP, X-Frame-Options…) | nginx/nginx.conf |
Authentication — JWT HS256
Every protected endpoint in the platform requires a valid JSON Web Token signed with HMAC-SHA256.Obtain a token
POST /api/auth/login with valid credentials. The server verifies the password with BCrypt, then issues a signed JWT.200:Send the token with every request
Pass the token in the
Authorization header on all protected endpoints:The
debe_cambiar_password: true field in the login response signals that an admin has reset this user’s password and they are using a system-generated temporary credential. The frontend should route the user to the password change screen immediately after login when this flag is true.Nginx auth_request subrequest
GET /api/auth/validacion is used internally by Nginx as an auth_request subrequest. It returns 200 if the JWT is valid and the iat claim is not in the future (with a 5-second tolerance for clock drift between containers). It returns 401 for any invalid or missing token. This endpoint is not intended to be called directly from client code.
CSRF — Double-Submit Cookie
All state-changing requests (POST, PUT, PATCH, DELETE) must carry a valid CSRF token. Stay Sidekick uses the Double-Submit Cookie pattern — a stateless approach that requires no server-side session storage.Fetch a CSRF token
Call
GET /api/csrf-token before making any write request. The backend generates a 256-bit cryptographically random token, sets it as a csrf_token cookie (SameSite=Strict, HttpOnly=false, max_age=3600), and returns it in the JSON body.Echo the token in the header
Read the token value from the cookie and include it in the
X-CSRF-Token header on every write request:The cookie is set with
SameSite=Strict so that cross-site requests cannot attach it automatically. HttpOnly=false is intentional — JavaScript must be able to read the cookie value to populate the request header. An attacker on a different origin is blocked by SameSite and CORS combined, not by cookie inaccessibility.Password hashing — BCrypt
All passwords stored in the database (company admin passwords and individual user passwords) are hashed with BCrypt.- BCrypt automatically generates a random salt on every call to
hash_password, so two identical passwords produce different hash strings — this is correct and expected behavior. - Verification (
verify_password) callsbcrypt.checkpw, which is a constant-time comparison implementation that prevents timing side-channel attacks. - The cost factor is configurable and baked into the hash string, meaning future rounds can be increased without invalidating existing hashes.
Rate limiting
Stay Sidekick uses Flask-Limiter to enforce per-IP request limits on all sensitive or potentially abused endpoints.| Endpoint | Limit | Notes |
|---|---|---|
POST /api/auth/login | 10 / hour | Mitigates brute-force password attacks |
| Contact form | 5 / hour (configurable via RATE_LIMIT_CONTACT) | Configurable via environment variable |
| Smoobu sync | 10 / hour | Prevents runaway PMS polling |
| XLSX import | 20–30 / hour | Varies by operation type |
429 Too Many Requests. The client should back off and retry after the reset window.
CORS
The platform restricts cross-origin requests to an explicit allow-list defined by theALLOWED_ORIGINS environment variable. Only origins on that list may include credentials in cross-origin requests. Any request from an unlisted origin will be rejected at the CORS preflight stage — the protected API endpoint is never reached.
This means that even if an attacker can trick a victim’s browser into making a request, the response will not be readable unless the origin matches.
Encrypted storage
PMS API keys (Smoobu) and AI provider API keys are stored in the database encrypted, never in plaintext. Encryption and decryption are handled byapp/common/crypto.py using Fernet symmetric encryption from the cryptography library.
- The Fernet key is loaded from the
FERNET_KEYenvironment variable at runtime. encrypt(plaintext)returns a URL-safe base64-encoded token that is safe to store in aTEXTcolumn.decrypt(token)returnsNoneif the token is invalid or was encrypted with a different key — preventing silent data corruption.- The decrypted key material is never serialized into any API response. Integration endpoints only report a connected/disconnected boolean status.
GDPR data handling
Stay Sidekick processes guest reservation data in several operational modules (heat map, late check-in notifications, Google Contacts sync). In every case, this data is handled in memory only and is never written to the database.Heat map
Reservation date ranges from the PMS or XLSX are aggregated in memory to produce daily load counts. No individual reservation record is persisted.
Late check-in
Today’s arrivals are fetched from the PMS or loaded from an uploaded XLSX, filtered in memory against the cutoff hour, and discarded after the response is sent.
Google Contacts sync
Guest names and phone numbers are processed in memory to build the contact upsert payload. They are not stored in Stay Sidekick’s database.
Anti-spam for public forms
The public contact form and company signup form are protected by three independent anti-spam layers applied in sequence:Cloudflare Turnstile
A Turnstile challenge token is required in every form submission. The backend verifies the token against Cloudflare’s API using
TURNSTILE_SECRET_KEY before processing the request. Bot traffic that cannot solve the challenge is rejected immediately.Honeypot field
A hidden form field (invisible to real users, visible to scrapers and bots) is included in the form schema. Any submission where the honeypot field is populated is silently rejected.
Nginx security headers
All HTTP responses — regardless of which upstream service generated them — pass through Nginx, which injects the following security headers:| Header | Value / Policy |
|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains |
Content-Security-Policy | Strict per-resource policy defined in nginx.conf |
X-Frame-Options | SAMEORIGIN |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
In production, TLS is terminated at the Railway edge — not inside the Nginx container. The Nginx configuration (
nginx.railway.conf) therefore listens only on port 80. HSTS and the other headers are still applied by Nginx to all responses; the HTTPS guarantee is enforced by Railway’s edge layer upstream of the container.Defense-in-depth summary
Each security control is independent. An attacker who bypasses one layer still faces the remaining layers:- No token → JWT middleware rejects the request before it reaches any handler.
- Stolen token, different origin → CORS blocks the browser from reading the response.
- Valid token, no CSRF cookie →
@csrf_protectrejects the write request with403. - Brute-force login → Rate limiter blocks after 10 attempts per hour per IP.
- Compromised database → BCrypt hashes are not reversible without the cost of a full brute-force; Fernet ciphertext is unreadable without
FERNET_KEY. - Guest data request → No guest personal data exists at rest to expose.

