[{"data":1,"prerenderedAt":928},["ShallowReactive",2],{"navigation":3,"\u002Freference\u002Fsecurity":80,"\u002Freference\u002Fsecurity-surround":923},[4,9,14,50,75],{"title":5,"path":6,"stem":7,"icon":8},"Getting started","\u002Fgetting-started","1.getting-started","i-lucide-rocket",{"title":10,"path":11,"stem":12,"icon":13},"Installation","\u002Finstallation","2.installation","i-lucide-hard-drive-download",{"title":15,"path":16,"stem":17,"children":18,"page":49},"Guide","\u002Fguide","3.guide",[19,24,29,34,39,44],{"title":20,"path":21,"stem":22,"icon":23},"Timer & entries","\u002Fguide\u002Ftimer-and-entries","3.guide\u002F1.timer-and-entries","i-lucide-timer",{"title":25,"path":26,"stem":27,"icon":28},"Projects, clients & tasks","\u002Fguide\u002Fprojects-clients-tasks","3.guide\u002F2.projects-clients-tasks","i-lucide-git-branch",{"title":30,"path":31,"stem":32,"icon":33},"Reports & calendar","\u002Fguide\u002Freports-calendar","3.guide\u002F3.reports-calendar","i-lucide-chart-column",{"title":35,"path":36,"stem":37,"icon":38},"Theme editor","\u002Fguide\u002Ftheme-editor","3.guide\u002F4.theme-editor","i-lucide-palette",{"title":40,"path":41,"stem":42,"icon":43},"Importing data","\u002Fguide\u002Fimport","3.guide\u002F5.import","i-lucide-hard-drive-upload",{"title":45,"path":46,"stem":47,"icon":48},"Team & organizations","\u002Fguide\u002Fteam","3.guide\u002F6.team","i-lucide-users",false,{"title":51,"path":52,"stem":53,"children":54,"page":49},"Reference","\u002Freference","4.reference",[55,60,65,70],{"title":56,"path":57,"stem":58,"icon":59},"Data model","\u002Freference\u002Fdata-model","4.reference\u002F1.data-model","i-lucide-database",{"title":61,"path":62,"stem":63,"icon":64},"API routes","\u002Freference\u002Fapi","4.reference\u002F2.api","i-lucide-route",{"title":66,"path":67,"stem":68,"icon":69},"Environment variables","\u002Freference\u002Fenv","4.reference\u002F3.env","i-lucide-settings-2",{"title":71,"path":72,"stem":73,"icon":74},"Security","\u002Freference\u002Fsecurity","4.reference\u002F4.security","i-lucide-shield",{"title":76,"path":77,"stem":78,"icon":79},"About","\u002Fabout","5.about","i-lucide-info",{"id":81,"title":71,"body":82,"description":917,"extension":918,"meta":919,"navigation":920,"path":72,"seo":921,"stem":73,"__hash__":922},"docs\u002F4.reference\u002F4.security.md",{"type":83,"value":84,"toc":900},"minimark",[85,89,94,99,130,134,226,229,254,277,303,306,314,332,342,363,373,378,403,423,443,447,471,475,503,507,535,548,552,555,644,667,671,726,730,736,843,847,850],[86,87,88],"p",{},"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.",[90,91,93],"h2",{"id":92},"what-the-app-does","What the app does",[95,96,98],"h3",{"id":97},"passwords","Passwords",[100,101,102,120,123],"ul",{},[103,104,105,106,110,111,115,116,119],"li",{},"Hashed with Node's ",[107,108,109],"strong",{},"scrypt"," (",[112,113,114],"code",{},"node:crypto","), one random salt per user, stored as ",[112,117,118],{},"salt:hex",". No plaintext, no reversible encryption, no third-party service.",[103,121,122],{},"Minimum 8 characters, enforced on register, reset and change. There is no complexity rule and no breach-list check.",[103,124,125,126,129],{},"Login answers a single generic ",[112,127,128],{},"401"," for 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.",[95,131,133],{"id":132},"sessions","Sessions",[100,135,136,147,185,188,203],{},[103,137,138,139,142,143,146],{},"Sessions are ",[107,140,141],{},"sealed, signed cookies"," (nuxt-auth-utils), encrypted with ",[112,144,145],{},"NUXT_SESSION_PASSWORD",". There is no session table, so there is nothing for an attacker to read server-side — and nothing to revoke individually.",[103,148,149,150,153,154,157,158,161,162,153,165,153,168,171,172,175,176,178,179,184],{},"The cookie is ",[112,151,152],{},"HttpOnly",", ",[112,155,156],{},"SameSite=Lax"," and ",[112,159,160],{},"Secure"," (sent only over HTTPS, or to a loopback address: ",[112,163,164],{},"localhost",[112,166,167],{},"127.x",[112,169,170],{},"[::1]","). ",[112,173,174],{},"NUXT_SESSION_COOKIE_SECURE=false"," removes only ",[112,177,160],{},", and Tick logs a warning at startup when it's off — see ",[180,181,183],"a",{"href":182},"#plain-http-and-the-secure-flag","Plain HTTP and the Secure flag",".",[103,186,187],{},"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.",[103,189,190,191,194,195,198,199,202],{},"Two other cookies exist and neither carries authority: ",[112,192,193],{},"nuxt-color-mode"," (the saved theme) and ",[112,196,197],{},"tick_tz"," (your IANA timezone, e.g. ",[112,200,201],{},"America\u002FDenver",", 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.",[103,204,205,206,209,210,213,214,217,218,222,223,225],{},"Revocation works through ",[112,207,208],{},"users.session_version",": a password ",[107,211,212],{},"reset"," invalidates every session, a password ",[107,215,216],{},"change"," invalidates every ",[219,220,221],"em",{},"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 rotating ",[112,224,145],{}," kills a live session (which logs out everybody).",[95,227,183],{"id":228},"plain-http-and-the-secure-flag",[86,230,231,232,234,235,238,239,153,241,153,243,245,246,249,250,253],{},"Browsers refuse to store a ",[112,233,160],{}," cookie that arrives over plain ",[112,236,237],{},"http:\u002F\u002F",", with one exception: loopback addresses (",[112,240,164],{},[112,242,167],{},[112,244,170],{},"). Open Tick at ",[112,247,248],{},"http:\u002F\u002F192.168.1.20:3000"," and signing in ",[219,251,252],{},"looks"," like it works, but you stay logged out. The sign-in page explains this.",[86,255,256,262,263,266,267,270,271,273,274,276],{},[107,257,258,259,261],{},"Setting ",[112,260,174],{}," 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 ",[107,264,265],{},"bearer token",": anyone who copies it ",[219,268,269],{},"is"," that user. Without ",[112,272,160],{},", 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 ",[112,275,237],{}," request to that host. Once taken:",[100,278,279,285,291],{},[103,280,281,284],{},[107,282,283],{},"It never expires."," Tick sessions have no maximum age.",[103,286,287,290],{},[107,288,289],{},"Logging out does not revoke it."," Logout only deletes the browser's copy.",[103,292,293,296,297,299,300,302],{},[107,294,295],{},"It dies only"," on a password change or reset (",[112,298,208],{},"), removal from the org, or rotating ",[112,301,145],{}," (which logs everyone out).",[86,304,305],{},"Passwords also cross the network in cleartext at sign-in, whatever this flag says. Plain HTTP exposes the password as well as the cookie.",[86,307,308],{},[107,309,310,311,313],{},"Still in force without ",[112,312,160],{},":",[100,315,316,321,326,329],{},[103,317,318,320],{},[112,319,152],{},": an XSS can't read the cookie.",[103,322,323,325],{},[112,324,156],{},": other sites can't make authenticated POSTs.",[103,327,328],{},"The CSP and the other response headers.",[103,330,331],{},"The live membership and role check on every request.",[86,333,334,335,337,338,341],{},"None of these help against someone watching the network. Also, without ",[112,336,160],{}," a network attacker can more easily plant a session cookie of their own — for example, sign you into ",[219,339,340],{},"their"," account, so your hours land in their workspace.",[86,343,344,347,348,350,351,353,354,356,357,359,360,362],{},[107,345,346],{},"Switching schemes on one hostname."," If that host already holds a ",[112,349,160],{}," cookie from an earlier HTTPS visit, a later plain-HTTP login (even with ",[112,352,174],{},") is blocked: the browser won't let an insecure response overwrite a ",[112,355,160],{}," 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 ",[112,358,174],{}," is already set. Logging out over HTTPS first does not fix it — logout leaves an empty ",[112,361,160],{}," 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.",[86,364,365,368,369,372],{},[107,366,367],{},"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.\n",[107,370,371],{},"Not acceptable:"," any shared, guest or public Wi-Fi, anything reachable from the internet, or a network whose other devices you don't control.",[86,374,375],{},[107,376,377],{},"Prefer, in this order:",[379,380,381,393,398],"ol",{},[103,382,383,389,390,184],{},[107,384,385,386,184],{},"Caddy with ",[112,387,388],{},"tls internal"," Real HTTPS on a LAN hostname; trust Caddy's root certificate once per device. See ",[112,391,392],{},"deploy\u002FCaddyfile.example",[103,394,395],{},[107,396,397],{},"A VPN or Tailscale with HTTPS certs.",[103,399,400],{},[107,401,402],{},"Your own internal CA with nginx variant A.",[86,404,405,406,408,409,412,413,110,416,412,419,422],{},"Only then ",[112,407,174],{},". Accepted values are ",[112,410,411],{},"true","\u002F",[112,414,415],{},"false",[112,417,418],{},"1",[112,420,421],{},"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.",[86,424,425,431,432,434,435,438,439,442],{},[107,426,427,428,430],{},"Behind a TLS reverse proxy, keep ",[112,429,160],{}," 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 ",[112,433,160],{}," 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 ",[112,436,437],{},"includeSubDomains",") stops working anyway, so ",[112,440,441],{},"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.",[95,444,446],{"id":445},"tenancy","Tenancy",[100,448,449,464],{},[103,450,451,452,455,456,459,460,463],{},"Every query is scoped by ",[112,453,454],{},"org_id",", and entry mutations additionally by ",[112,457,458],{},"user_id",". Ids are UUIDs and validated as such, so a guessed id from another org returns ",[112,461,462],{},"404",", not data.",[103,465,466,467,470],{},"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 ",[112,468,469],{},"owner"," role or manage other owners; the last owner cannot be demoted or removed.",[95,472,474],{"id":473},"public-endpoints","Public endpoints",[100,476,477,486,496],{},[103,478,479,157,482,485],{},[112,480,481],{},"POST \u002Fapi\u002Fauth\u002F{login,register,forgot,reset}",[112,483,484],{},"GET \u002Fapi\u002Finvites\u002Flookup\u002F:token"," are the only routes reachable without a session.",[103,487,488,491,492,495],{},[112,489,490],{},"POST \u002Fapi\u002Fauth\u002Fforgot"," always answers the same ",[112,493,494],{},"{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.",[103,497,498,499,502],{},"Validation errors on those routes are deliberately terse — ",[112,500,501],{},"{statusMessage: 'Invalid input', data: {fields: […]}}",", field names only — so schema internals never leak.",[95,504,506],{"id":505},"rate-limiting","Rate limiting",[86,508,509,512,513,516,517,520,521,524,525,531,532,534],{},[112,510,511],{},"POST"," to each auth endpoint is limited to ",[107,514,515],{},"10 requests per IP per minute"," by default; over the limit the server answers ",[112,518,519],{},"429"," with a ",[112,522,523],{},"Retry-After"," header. Set ",[180,526,528],{"href":527},"\u002Freference\u002Fenv#variables",[112,529,530],{},"NUXT_AUTH_RATE_LIMIT"," to change the ceiling, or ",[112,533,421],{}," to disable it and let a proxy do the job.",[536,537,538],"note",{},[86,539,540,541,544,545,184],{},"The window is in-memory and keyed on the socket address — not ",[112,542,543],{},"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 ",[112,546,547],{},"NUXT_AUTH_RATE_LIMIT=0",[95,549,551],{"id":550},"response-headers","Response headers",[86,553,554],{},"Middleware sets these on every response — pages, assets and API alike:",[556,557,558,571],"table",{},[559,560,561],"thead",{},[562,563,564,568],"tr",{},[565,566,567],"th",{},"Header",[565,569,570],{},"Value",[572,573,574,608,620,632],"tbody",{},[562,575,576,582],{},[577,578,579],"td",{},[112,580,581],{},"Content-Security-Policy",[577,583,584,587,588,591,592,595,596,153,599,153,602,153,605],{},[112,585,586],{},"default-src 'self'",", no external script hosts (only ",[112,589,590],{},"fonts.googleapis.com"," \u002F ",[112,593,594],{},"fonts.gstatic.com"," for fonts), ",[112,597,598],{},"object-src 'none'",[112,600,601],{},"base-uri 'self'",[112,603,604],{},"form-action 'self'",[112,606,607],{},"frame-ancestors 'none'",[562,609,610,615],{},[577,611,612],{},[112,613,614],{},"X-Frame-Options",[577,616,617],{},[112,618,619],{},"DENY",[562,621,622,627],{},[577,623,624],{},[112,625,626],{},"X-Content-Type-Options",[577,628,629],{},[112,630,631],{},"nosniff",[562,633,634,639],{},[577,635,636],{},[112,637,638],{},"Referrer-Policy",[577,640,641],{},[112,642,643],{},"strict-origin-when-cross-origin",[86,645,646,649,650,653,654,657,658,661,662,666],{},[112,647,648],{},"x-powered-by"," is stripped. ",[112,651,652],{},"script-src"," does include ",[112,655,656],{},"'unsafe-inline'"," — Nuxt's inline hydration payload requires it — so the CSP is a defense in depth, not a hermetic seal. ",[107,659,660],{},"HSTS is not set by the app",": it belongs at the TLS-terminating proxy, and both shipped ",[180,663,665],{"href":664},"\u002Finstallation#reverse-proxy","proxy configs"," carry it commented out.",[95,668,670],{"id":669},"input-and-payload-limits","Input and payload limits",[100,672,673,699,706],{},[103,674,675,676,110,679,682,683,686,687,690,691,694,695,698],{},"API request bodies are capped at ",[107,677,678],{},"1 MB",[112,680,681],{},"413","); a mutation without a ",[112,684,685],{},"Content-Length"," is refused with ",[112,688,689],{},"411"," so a chunked body cannot slip past the check. ",[112,692,693],{},"\u002Fapi\u002Fimport\u002F*"," has its own ",[107,696,697],{},"5 MB"," cap.",[103,700,701,702,705],{},"Every body, query and route param goes through zod. ",[112,703,704],{},"users.theme"," accepts known keys only and is capped at 2 KB — a jsonb column is not a dumping ground.",[103,707,708,709,712,713,153,716,153,719,153,722,725],{},"CSV export escapes per RFC 4180 ",[107,710,711],{},"and"," neutralizes spreadsheet formula injection: a text field starting with ",[112,714,715],{},"=",[112,717,718],{},"+",[112,720,721],{},"-",[112,723,724],{},"@",", tab or CR is prefixed with an apostrophe, so a maliciously named entry cannot execute in Excel or Sheets.",[90,727,729],{"id":728},"what-you-have-to-do","What you have to do",[731,732,733],"warning",{},[86,734,735],{},"Tick speaks plain HTTP and assumes something in front of it. The list below is not optional for anything reachable beyond localhost.",[379,737,738,763,772,783,803,813,827,833],{},[103,739,740,743,744,747,748,756,757,759,760,762],{},[107,741,742],{},"Terminate TLS at a reverse proxy",", and leave ",[112,745,746],{},"NUXT_SESSION_COOKIE_SECURE"," at its default. Use the shipped ",[180,749,750,753,754],{"href":664},[112,751,752],{},"deploy\u002Fnginx.conf.example"," or ",[112,755,392],{},". Plain HTTP beyond localhost needs ",[112,758,174],{},". That puts both passwords and session cookies in cleartext, so only do it on a network you fully trust (see ",[180,761,183],{"href":182},").",[103,764,765,768,769,184],{},[107,766,767],{},"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 ",[112,770,771],{},"max-age",[103,773,774,110,779,782],{},[107,775,776,777],{},"Generate a strong ",[112,778,145],{},[112,780,781],{},"openssl rand -base64 36","), keep it out of version control, and keep it stable. It is the key to every session cookie.",[103,784,785,792,793,796,797,753,800,184],{},[107,786,787,788,184],{},"Pick a ",[180,789,791],{"href":790},"\u002Finstallation#registration-modes","registration mode"," The default is ",[112,794,795],{},"open"," — anyone who can reach the host can create a workspace. Public instances should run ",[112,798,799],{},"invite",[112,801,802],{},"closed",[103,804,805,808,809,812],{},[107,806,807],{},"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 ",[112,810,811],{},"NUXT_AUTO_MIGRATE=false"," the runtime user needs no DDL rights at all.",[103,814,815,818,819,822,823,826],{},[107,816,817],{},"Back up Postgres."," The 30-day trash is an undo affordance, not a backup — ",[219,820,821],{},"Delete forever",", a purge past retention and a dropped volume are all unrecoverable. ",[112,824,825],{},"pg_dump"," on a schedule, restore-tested.",[103,828,829,832],{},[107,830,831],{},"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.",[103,834,835,842],{},[107,836,837,838,841],{},"Never run ",[180,839,840],{"href":527},"demo mode"," against real data."," It wipes and reseeds the demo org on boot.",[90,844,846],{"id":845},"known-gaps","Known gaps",[86,848,849],{},"Stated plainly, so nobody is surprised:",[100,851,852,858,864,870,879,885,891],{},[103,853,854,857],{},[107,855,856],{},"No email verification."," A registered address is never confirmed.",[103,859,860,863],{},[107,861,862],{},"No two-factor auth",", no SSO, no OIDC.",[103,865,866,869],{},[107,867,868],{},"No per-session revocation."," You can log out everywhere (password change or reset), not a single device.",[103,871,872,875,876,184],{},[107,873,874],{},"No audit log."," Who deleted what is not recorded beyond the trash's ",[112,877,878],{},"deleted_at",[103,880,881,884],{},[107,882,883],{},"No field-level encryption."," Everything is readable by anyone with database access.",[103,886,887,890],{},[107,888,889],{},"Rate limiting is single-node and in-memory"," — see the note above.",[103,892,893,896,897,899],{},[107,894,895],{},"Sessions don't expire; logout doesn't revoke a copied cookie."," Only a password change or reset, removal from the org, or rotating ",[112,898,145],{}," kills one.",{"title":901,"searchDepth":902,"depth":902,"links":903},"",2,[904,915,916],{"id":92,"depth":902,"text":93,"children":905},[906,908,909,910,911,912,913,914],{"id":97,"depth":907,"text":98},3,{"id":132,"depth":907,"text":133},{"id":228,"depth":907,"text":183},{"id":445,"depth":907,"text":446},{"id":473,"depth":907,"text":474},{"id":505,"depth":907,"text":506},{"id":550,"depth":907,"text":551},{"id":669,"depth":907,"text":670},{"id":728,"depth":902,"text":729},{"id":845,"depth":902,"text":846},"What Tick does to protect an instance, and what you as the operator still have to do.","md",{},{"icon":74},{"title":71,"description":917},"OpOzDnfx3RV17VyN6oZWyyewfkdDahbK0PE1xS_NFDY",[924,926],{"title":66,"path":67,"stem":68,"description":925,"icon":69,"children":-1},"Every variable Tick reads, plus the npm scripts you'll use in development.",{"title":76,"path":77,"stem":78,"description":927,"icon":79,"children":-1},"License, credits and the project's inspiration.",1790777312482]