Security
Tick is a small self-hosted app, not a managed service. This page is the honest inventory: what the code does, and what it leaves to you. Nothing here claims an audit.
What the app does
Passwords
- Hashed with Node's scrypt (
node:crypto), one random salt per user, stored assalt:hex. No plaintext, no reversible encryption, no third-party service. - Minimum 8 characters, enforced on register, reset and change. There is no complexity rule and no breach-list check.
- Login answers a single generic
401for both a wrong email and a wrong password, and verifies against a dummy hash when the email is unknown so response time does not reveal whether an account exists.
Sessions
- Sessions are sealed, signed cookies (nuxt-auth-utils), encrypted with
NUXT_SESSION_PASSWORD. There is no session table, so there is nothing for an attacker to read server-side — and nothing to revoke individually. - The cookie is
HttpOnly,SameSite=LaxandSecure(sent only over HTTPS, or to a loopback address:localhost,127.x,[::1]).NUXT_SESSION_COOKIE_SECURE=falseremoves onlySecure, and Tick logs a warning at startup when it's off — see Plain HTTP and the Secure flag. - Authorization is never trusted from the cookie. Every authenticated request re-reads the caller's org membership and role from the database: a removed member is locked out on the next request, a demoted one loses privileges immediately.
- Two other cookies exist and neither carries authority:
nuxt-color-mode(the saved theme) andtick_tz(your IANA timezone, e.g.America/Denver, so the server renders dates in your zone rather than the container's). Both are readable by page scripts by design, and both are ignored unless they hold a value the server recognises — an unknown timezone falls back to the server's own. - Revocation works through
users.session_version: a password reset invalidates every session, a password change invalidates every other session and keeps the current one. Removal from the org also cuts a live session off, on its next request. Short of that, only rotatingNUXT_SESSION_PASSWORDkills a live session (which logs out everybody).
Plain HTTP and the Secure flag
Browsers refuse to store a Secure cookie that arrives over plain http://, with one exception: loopback addresses (localhost, 127.x, [::1]). Open Tick at http://192.168.1.20:3000 and signing in looks like it works, but you stay logged out. The sign-in page explains this.
Setting NUXT_SESSION_COOKIE_SECURE=false fixes this, and here is what it costs. The cookie is encrypted and signed, so nobody can read or forge its contents. It is still a bearer token: anyone who copies it is that user. Without Secure, it travels in cleartext on every request. Anyone who can see the traffic can take it and replay it: someone on the same Wi-Fi, a compromised switch or router, or ARP spoofing on the LAN. It also goes out on any plain http:// request to that host. Once taken:
- It never expires. Tick sessions have no maximum age.
- Logging out does not revoke it. Logout only deletes the browser's copy.
- It dies only on a password change or reset (
users.session_version), removal from the org, or rotatingNUXT_SESSION_PASSWORD(which logs everyone out).
Passwords also cross the network in cleartext at sign-in, whatever this flag says. Plain HTTP exposes the password as well as the cookie.
Still in force without Secure:
HttpOnly: an XSS can't read the cookie.SameSite=Lax: other sites can't make authenticated POSTs.- The CSP and the other response headers.
- The live membership and role check on every request.
None of these help against someone watching the network. Also, without Secure a network attacker can more easily plant a session cookie of their own — for example, sign you into their account, so your hours land in their workspace.
Switching schemes on one hostname. If that host already holds a Secure cookie from an earlier HTTPS visit, a later plain-HTTP login (even with NUXT_SESSION_COOKIE_SECURE=false) is blocked: the browser won't let an insecure response overwrite a Secure cookie, so you stay on the login page. The on-page message covers this case: it tells you to clear the site's cookies if NUXT_SESSION_COOKIE_SECURE=false is already set. Logging out over HTTPS first does not fix it — logout leaves an empty Secure cookie in place. Clear that site's cookies (or its stored data) in the browser, then log in over HTTP. Avoiding the mix in the first place — one scheme per hostname — avoids this entirely.
Acceptable: a network where you trust every device and path. A single-user home LAN, or a VPN or overlay network (WireGuard, Tailscale) that encrypts the traffic itself, count. Not acceptable: any shared, guest or public Wi-Fi, anything reachable from the internet, or a network whose other devices you don't control.
Prefer, in this order:
- Caddy with
tls internal. Real HTTPS on a LAN hostname; trust Caddy's root certificate once per device. Seedeploy/Caddyfile.example. - A VPN or Tailscale with HTTPS certs.
- Your own internal CA with nginx variant A.
Only then NUXT_SESSION_COOKIE_SECURE=false. Accepted values are true/false (1/0 also work); an empty value or an unrecognized value stops Tick at startup rather than guessing, and Tick logs a warning at boot whenever it's off.
Behind a TLS reverse proxy, keep Secure on (the default). The browser only ever talks https to the proxy, so the cookie works even though the proxy reaches Tick over http. A cookie without Secure would also be sent on the plain-http request that port 80 answers with a redirect, leaking it before the redirect happens. HSTS closes that hole only after the first https visit, or with preload. Also, once a hostname has sent HSTS, browsers force https for it — plain-http access to that name (and to its subdomains, with includeSubDomains) stops working anyway, so Secure=false gains nothing there. If the same hostname is reachable over both https (Secure cookie) and http, the http side can't replace the existing Secure cookie — see "Switching schemes on one hostname" above for what that looks like and how to recover.
Tenancy
- Every query is scoped by
org_id, and entry mutations additionally byuser_id. Ids are UUIDs and validated as such, so a guessed id from another org returns404, not data. - Role gates live on the server, not just in the UI: only owners and admins can rename the org, manage members or manage invites; only owners can create or grant the
ownerrole or manage other owners; the last owner cannot be demoted or removed.
Public endpoints
POST /api/auth/{login,register,forgot,reset}andGET /api/invites/lookup/:tokenare the only routes reachable without a session.POST /api/auth/forgotalways answers the same{ok: true}and pads its response time, so it cannot be used to enumerate accounts. Reset tokens are 36 random bytes, single-use, and expire after one hour.- Validation errors on those routes are deliberately terse —
{statusMessage: 'Invalid input', data: {fields: […]}}, field names only — so schema internals never leak.
Rate limiting
POST to each auth endpoint is limited to 10 requests per IP per minute by default; over the limit the server answers 429 with a Retry-After header. Set NUXT_AUTH_RATE_LIMIT to change the ceiling, or 0 to disable it and let a proxy do the job.
X-Forwarded-For, which a client can forge when the app is exposed directly. Behind a reverse proxy every request appears to come from the proxy, so the limit becomes effectively instance-wide. Multi-node deployments should rate-limit at the proxy and set NUXT_AUTH_RATE_LIMIT=0.Response headers
Middleware sets these on every response — pages, assets and API alike:
| Header | Value |
|---|---|
Content-Security-Policy | default-src 'self', no external script hosts (only fonts.googleapis.com / fonts.gstatic.com for fonts), object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'none' |
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
x-powered-by is stripped. script-src does include 'unsafe-inline' — Nuxt's inline hydration payload requires it — so the CSP is a defense in depth, not a hermetic seal. HSTS is not set by the app: it belongs at the TLS-terminating proxy, and both shipped proxy configs carry it commented out.
Input and payload limits
- API request bodies are capped at 1 MB (
413); a mutation without aContent-Lengthis refused with411so a chunked body cannot slip past the check./api/import/*has its own 5 MB cap. - Every body, query and route param goes through zod.
users.themeaccepts known keys only and is capped at 2 KB — a jsonb column is not a dumping ground. - CSV export escapes per RFC 4180 and neutralizes spreadsheet formula injection: a text field starting with
=,+,-,@, tab or CR is prefixed with an apostrophe, so a maliciously named entry cannot execute in Excel or Sheets.
What you have to do
- Terminate TLS at a reverse proxy, and leave
NUXT_SESSION_COOKIE_SECUREat its default. Use the shippeddeploy/nginx.conf.exampleordeploy/Caddyfile.example. Plain HTTP beyond localhost needsNUXT_SESSION_COOKIE_SECURE=false. That puts both passwords and session cookies in cleartext, so only do it on a network you fully trust (see Plain HTTP and the Secure flag). - Add HSTS at the proxy — but only once the certificate is known-good. Browsers cache it, and a broken cert then locks users out for the whole
max-age. - Generate a strong
NUXT_SESSION_PASSWORD(openssl rand -base64 36), keep it out of version control, and keep it stable. It is the key to every session cookie. - Pick a registration mode. The default is
open— anyone who can reach the host can create a workspace. Public instances should runinviteorclosed. - Restrict the database. Postgres is not exposed to the host by the shipped Compose file; keep it that way, and give the app its own role rather than a superuser. With
NUXT_AUTO_MIGRATE=falsethe runtime user needs no DDL rights at all. - Back up Postgres. The 30-day trash is an undo affordance, not a backup — Delete forever, a purge past retention and a dropped volume are all unrecoverable.
pg_dumpon a schedule, restore-tested. - Watch the logs if you run without SMTP: password-reset links are printed to the server console there, which means anyone who can read your logs can take over an account.
- Never run demo mode against real data. It wipes and reseeds the demo org on boot.
Known gaps
Stated plainly, so nobody is surprised:
- No email verification. A registered address is never confirmed.
- No two-factor auth, no SSO, no OIDC.
- No per-session revocation. You can log out everywhere (password change or reset), not a single device.
- No audit log. Who deleted what is not recorded beyond the trash's
deleted_at. - No field-level encryption. Everything is readable by anyone with database access.
- Rate limiting is single-node and in-memory — see the note above.
- Sessions don't expire; logout doesn't revoke a copied cookie. Only a password change or reset, removal from the org, or rotating
NUXT_SESSION_PASSWORDkills one.