Skip to main content

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.

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.

Security controls at a glance

LayerMechanismImplementation
AuthenticationJWT HS256, configurable TTLapp/auth/routes.py, app/security/jwt.py
Request integrityCSRF Double-Submit Cookieapp/security/csrf.py
Password storageBCrypt with random saltapp/auth/passwords.py
Rate limitingFlask-Limiter, per-IPapp/extensions.py, individual blueprints
Cross-origin accessCORS allow-listApp factory
Credential storageFernet symmetric encryptionapp/common/crypto.py
Guest dataIn-memory only, never persistedAll operational modules
Public formsCloudflare Turnstile + honeypotapp/contact/, app/security/
Security headersNginx (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.
1

Obtain a token

POST /api/auth/login with valid credentials. The server verifies the password with BCrypt, then issues a signed JWT.
curl -X POST http://localhost/api/auth/login \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <csrf_token>" \
  -d '{
    "email": "julia@coastalstays.com",
    "password": "MyNewSecure#99"
  }'
Response 200:
{
  "ok": true,
  "token": "<jwt>",
  "debe_cambiar_password": false
}
2

Send the token with every request

Pass the token in the Authorization header on all protected endpoints:
curl http://localhost/api/perfil \
  -H "Authorization: Bearer $TOKEN"
3

Token expiry

The TTL is controlled by the JWT_ACCESS_TOKEN_HOURS environment variable (default: 1 hour). After expiry, the client must re-authenticate. There is no refresh token — the login flow is the only way to obtain a new token.
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.
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.
1

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.
curl -c cookies.txt http://localhost/api/csrf-token
{ "csrf_token": "yK3r...NpQx" }
2

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:
curl -X POST http://localhost/api/empresas \
  -b cookies.txt \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-CSRF-Token: yK3r...NpQx" \
  -H "Content-Type: application/json" \
  -d '{"nombre": "...", "email": "..."}'
3

Server validation

The @csrf_protect decorator in app/security/csrf.py compares the csrf_token cookie value with the X-CSRF-Token header using secrets.compare_digest (constant-time comparison). If either is absent or they do not match, the request is rejected with 403.
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) calls bcrypt.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.
# app/auth/passwords.py
hash_str = hash_password("MyNewSecure#99")   # → store in DB
ok       = verify_password("MyNewSecure#99", hash_str)  # → verify at login
Never compare password hashes with == or any string equality operator. Always go through verify_password to benefit from the constant-time comparison and prevent timing attacks.

Rate limiting

Stay Sidekick uses Flask-Limiter to enforce per-IP request limits on all sensitive or potentially abused endpoints.
EndpointLimitNotes
POST /api/auth/login10 / hourMitigates brute-force password attacks
Contact form5 / hour (configurable via RATE_LIMIT_CONTACT)Configurable via environment variable
Smoobu sync10 / hourPrevents runaway PMS polling
XLSX import20–30 / hourVaries by operation type
When a limit is exceeded the server returns 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 the ALLOWED_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 by app/common/crypto.py using Fernet symmetric encryption from the cryptography library.
  • The Fernet key is loaded from the FERNET_KEY environment variable at runtime.
  • encrypt(plaintext) returns a URL-safe base64-encoded token that is safe to store in a TEXT column.
  • decrypt(token) returns None if 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.
# Generate a new Fernet key (run once, store in environment)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
If FERNET_KEY is rotated after API keys have been stored, all existing encrypted values will fail to decrypt (decrypt() returns None). Plan key rotations carefully — decrypt and re-encrypt all stored keys before swapping the environment variable in production.

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.
This architecture means Stay Sidekick holds no guest personal data at rest. The platform is GDPR-compliant by design for these data flows — there is no guest data to report, export, or delete in response to a data subject request.

Anti-spam for public forms

The public contact form and company signup form are protected by three independent anti-spam layers applied in sequence:
1

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.
2

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.
3

Rate limit by IP

The contact endpoint enforces a rate limit (default: 5 requests / hour per IP, configurable via RATE_LIMIT_CONTACT). Distributed abuse is further constrained by Cloudflare’s edge network.

Nginx security headers

All HTTP responses — regardless of which upstream service generated them — pass through Nginx, which injects the following security headers:
HeaderValue / Policy
Strict-Transport-Securitymax-age=31536000; includeSubDomains
Content-Security-PolicyStrict per-resource policy defined in nginx.conf
X-Frame-OptionsSAMEORIGIN
X-Content-Type-Optionsnosniff
Referrer-Policystrict-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:
  1. No token → JWT middleware rejects the request before it reaches any handler.
  2. Stolen token, different origin → CORS blocks the browser from reading the response.
  3. Valid token, no CSRF cookie@csrf_protect rejects the write request with 403.
  4. Brute-force login → Rate limiter blocks after 10 attempts per hour per IP.
  5. Compromised database → BCrypt hashes are not reversible without the cost of a full brute-force; Fernet ciphertext is unreadable without FERNET_KEY.
  6. Guest data request → No guest personal data exists at rest to expose.

Build docs developers (and LLMs) love