[{"data":1,"prerenderedAt":540},["ShallowReactive",2],{"doc-\u002Fapi\u002Fauth":3},{"id":4,"title":5,"body":6,"description":530,"edit":531,"extension":532,"meta":533,"navigation":534,"path":535,"seo":536,"stem":537,"vertical":531,"weight":538,"__hash__":539},"content\u002Fapi\u002Fauth.md","Authentication",{"type":7,"value":8,"toc":515},"minimark",[9,22,27,30,66,73,76,80,87,159,166,171,191,195,199,251,254,258,291,294,351,358,362,368,402,405,409,429,432,436,468,479,483,498,502,511],[10,11,12,13,17,18,21],"p",{},"OpenSense uses ",[14,15,16],"strong",{},"magic links"," for human authentication and ",[14,19,20],{},"bearer\ntokens"," for machine authentication. There are no passwords, no\nTOTP, no SAML (yet).",[23,24,26],"h2",{"id":25},"human-auth-magic-link","Human auth — magic link",[10,28,29],{},"The web UI (\"the dashboard\") authenticates by emailing a short-lived\ncode:",[31,32,33,42,49,52],"ol",{},[34,35,36,37,41],"li",{},"User enters their email at ",[38,39,40],"code",{},"\u002Flogin",".",[34,43,44,45,48],{},"We email a six-character alphanumeric code (",[38,46,47],{},"A4F-9B2",") plus a clickable\nlink.",[34,50,51],{},"User pastes the code in the same browser session, or clicks the link.",[34,53,54,55,58,59,58,62,65],{},"Session cookie issued, ",[38,56,57],{},"httpOnly",", ",[38,60,61],{},"Secure",[38,63,64],{},"SameSite=Lax",", expires\nafter 30 days of inactivity.",[10,67,68,69,72],{},"The code expires after ",[14,70,71],{},"10 minutes"," and is single-use. A second\nrequest for the same email within the window invalidates the first.",[10,74,75],{},"For email providers that strip the code from inbox previews (some\nspam filters do), the link suffices: clicking it sets the cookie\ndirectly.",[23,77,79],{"id":78},"machine-auth-bearer-tokens","Machine auth — bearer tokens",[10,81,82,83,86],{},"Three token types, all carried as ",[38,84,85],{},"Authorization: Bearer \u003Ctoken>",":",[88,89,90,109],"table",{},[91,92,93],"thead",{},[94,95,96,100,103,106],"tr",{},[97,98,99],"th",{},"Prefix",[97,101,102],{},"Scope",[97,104,105],{},"Issued by",[97,107,108],{},"Rotation",[110,111,112,129,144],"tbody",{},[94,113,114,120,123,126],{},[115,116,117],"td",{},[38,118,119],{},"ds_live_",[115,121,122],{},"One device's ingest endpoints only",[115,124,125],{},"Dashboard, add-device flow",[115,127,128],{},"Any time, dashboard",[94,130,131,136,139,142],{},[115,132,133],{},[38,134,135],{},"ua_live_",[115,137,138],{},"Account management endpoints; user-scoped",[115,140,141],{},"Dashboard, account settings",[115,143,128],{},[94,145,146,151,154,156],{},[115,147,148],{},[38,149,150],{},"whk_",[115,152,153],{},"Inbound webhook (TTN, Shelly URL action)",[115,155,125],{},[115,157,158],{},"Any time",[10,160,161,162,165],{},"Tokens are bcrypt-hashed at rest; the plaintext is shown ",[14,163,164],{},"once"," at\nissuance time. Lose the plaintext, rotate.",[167,168,170],"h3",{"id":169},"token-rotation","Token rotation",[172,173,174,177,184],"ul",{},[34,175,176],{},"All token rotations are immediate. Old token returns 401 on the next\nrequest.",[34,178,179,180,183],{},"The dashboard's ",[38,181,182],{},"Tokens"," page shows last-used timestamps so you can\nspot abandoned devices.",[34,185,186,187,190],{},"Tokens older than ",[14,188,189],{},"365 days"," are flagged in the dashboard as\n\"consider rotating\". They continue to work; the flag is advisory.",[23,192,194],{"id":193},"endpoints","Endpoints",[167,196,198],{"id":197},"post-v1authrequest","POST \u002Fv1\u002Fauth\u002Frequest",[200,201,206],"pre",{"className":202,"code":203,"language":204,"meta":205,"style":205},"language-bash shiki shiki-themes github-dark github-dark","curl -X POST https:\u002F\u002Fapi.opensense.murzin.digital\u002Fv1\u002Fauth\u002Frequest \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"email\": \"you@example.com\"}'\n","bash","",[38,207,208,231,242],{"__ignoreMap":205},[209,210,213,217,221,225,228],"span",{"class":211,"line":212},"line",1,[209,214,216],{"class":215},"sFR8T","curl",[209,218,220],{"class":219},"s8ozJ"," -X",[209,222,224],{"class":223},"s4wv1"," POST",[209,226,227],{"class":223}," https:\u002F\u002Fapi.opensense.murzin.digital\u002Fv1\u002Fauth\u002Frequest",[209,229,230],{"class":219}," \\\n",[209,232,234,237,240],{"class":211,"line":233},2,[209,235,236],{"class":219},"  -H",[209,238,239],{"class":223}," \"Content-Type: application\u002Fjson\"",[209,241,230],{"class":219},[209,243,245,248],{"class":211,"line":244},3,[209,246,247],{"class":219},"  -d",[209,249,250],{"class":223}," '{\"email\": \"you@example.com\"}'\n",[10,252,253],{},"Always returns 200 OK, regardless of whether the email exists — we do\nnot allow account enumeration.",[167,255,257],{"id":256},"post-v1authverify","POST \u002Fv1\u002Fauth\u002Fverify",[200,259,261],{"className":202,"code":260,"language":204,"meta":205,"style":205},"curl -X POST https:\u002F\u002Fapi.opensense.murzin.digital\u002Fv1\u002Fauth\u002Fverify \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"email\": \"you@example.com\", \"code\": \"A4F9B2\"}'\n",[38,262,263,276,284],{"__ignoreMap":205},[209,264,265,267,269,271,274],{"class":211,"line":212},[209,266,216],{"class":215},[209,268,220],{"class":219},[209,270,224],{"class":223},[209,272,273],{"class":223}," https:\u002F\u002Fapi.opensense.murzin.digital\u002Fv1\u002Fauth\u002Fverify",[209,275,230],{"class":219},[209,277,278,280,282],{"class":211,"line":233},[209,279,236],{"class":219},[209,281,239],{"class":223},[209,283,230],{"class":219},[209,285,286,288],{"class":211,"line":244},[209,287,247],{"class":219},[209,289,290],{"class":223}," '{\"email\": \"you@example.com\", \"code\": \"A4F9B2\"}'\n",[10,292,293],{},"Returns:",[200,295,299],{"className":296,"code":297,"language":298,"meta":205,"style":205},"language-json shiki shiki-themes github-dark github-dark","{\n  \"session_id\": \"ses_4f3c…\",\n  \"expires_at\": \"2026-06-16T08:22:00Z\",\n  \"user_id\":    \"usr_b1d2…\"\n}\n","json",[38,300,301,307,321,333,345],{"__ignoreMap":205},[209,302,303],{"class":211,"line":212},[209,304,306],{"class":305},"suv1-","{\n",[209,308,309,312,315,318],{"class":211,"line":233},[209,310,311],{"class":219},"  \"session_id\"",[209,313,314],{"class":305},": ",[209,316,317],{"class":223},"\"ses_4f3c…\"",[209,319,320],{"class":305},",\n",[209,322,323,326,328,331],{"class":211,"line":244},[209,324,325],{"class":219},"  \"expires_at\"",[209,327,314],{"class":305},[209,329,330],{"class":223},"\"2026-06-16T08:22:00Z\"",[209,332,320],{"class":305},[209,334,336,339,342],{"class":211,"line":335},4,[209,337,338],{"class":219},"  \"user_id\"",[209,340,341],{"class":305},":    ",[209,343,344],{"class":223},"\"usr_b1d2…\"\n",[209,346,348],{"class":211,"line":347},5,[209,349,350],{"class":305},"}\n",[10,352,353,354,357],{},"Set the session cookie via the ",[38,355,356],{},"Set-Cookie"," response header.",[167,359,361],{"id":360},"post-v1tokens","POST \u002Fv1\u002Ftokens",[10,363,364,365,367],{},"User-scoped: requires a session cookie or another ",[38,366,135],{}," token.",[200,369,371],{"className":202,"code":370,"language":204,"meta":205,"style":205},"curl -X POST https:\u002F\u002Fapi.opensense.murzin.digital\u002Fv1\u002Ftokens \\\n  -H \"Authorization: Bearer ua_live_4f3c…\" \\\n  -d '{ \"label\": \"ci-pipeline\", \"scope\": \"read\" }'\n",[38,372,373,386,395],{"__ignoreMap":205},[209,374,375,377,379,381,384],{"class":211,"line":212},[209,376,216],{"class":215},[209,378,220],{"class":219},[209,380,224],{"class":223},[209,382,383],{"class":223}," https:\u002F\u002Fapi.opensense.murzin.digital\u002Fv1\u002Ftokens",[209,385,230],{"class":219},[209,387,388,390,393],{"class":211,"line":233},[209,389,236],{"class":219},[209,391,392],{"class":223}," \"Authorization: Bearer ua_live_4f3c…\"",[209,394,230],{"class":219},[209,396,397,399],{"class":211,"line":244},[209,398,247],{"class":219},[209,400,401],{"class":223}," '{ \"label\": \"ci-pipeline\", \"scope\": \"read\" }'\n",[10,403,404],{},"Returns the plaintext token once. Save it.",[23,406,408],{"id":407},"why-not-oauth-saml-passwords","Why not OAuth \u002F SAML \u002F passwords?",[172,410,411,417,423],{},[34,412,413,416],{},[14,414,415],{},"Passwords"," are the worst-of-both: bad for security, bad for UX,\nexpensive to operate (forgot-password, leaked-password databases,\nrotation prompts).",[34,418,419,422],{},[14,420,421],{},"OAuth via Google\u002FMicrosoft",": locks small EU operators into US\nidentity providers; awkward when the bookkeeper resigns and the\nGmail account is closed.",[34,424,425,428],{},[14,426,427],{},"SAML \u002F SSO",": solid for enterprise; not in scope for solo cafés.\nWe will add it at the Team tier.",[10,430,431],{},"Magic-link plus session cookies is what most modern EU SaaS in this\nsegment uses (Linear, Stripe Atlas, Notion login). It is enough.",[23,433,435],{"id":434},"session-cookie-details","Session cookie details",[172,437,438,444,450,456,465],{},[34,439,440,441,41],{},"Name: ",[38,442,443],{},"os_session",[34,445,446,447,41],{},"Path: ",[38,448,449],{},"\u002F",[34,451,452,453,41],{},"Domain: ",[38,454,455],{},"opensense.murzin.digital",[34,457,458,58,461,58,463,41],{},[38,459,460],{},"HttpOnly",[38,462,61],{},[38,464,64],{},[34,466,467],{},"Rolling 30-day expiry — every request extends it.",[10,469,470,471,474,475,478],{},"The cookie is ",[14,472,473],{},"not"," valid against the API; the API is ",[38,476,477],{},"Authorization: Bearer …"," only. This means a malicious site cannot CSRF an API call\neven if it can read the cookie name.",[23,480,482],{"id":481},"csrf-protection","CSRF protection",[10,484,485,486,489,490,493,494,497],{},"For state-changing requests against the dashboard's own JSON endpoints\n(",[38,487,488],{},"\u002Fapp\u002Fapi\u002F…","), we require a ",[38,491,492],{},"X-Requested-With: XMLHttpRequest"," header\n",[14,495,496],{},"and"," the cookie. The pair makes simple form-CSRF impossible.",[23,499,501],{"id":500},"logout","Logout",[10,503,504,507,508,41],{},[38,505,506],{},"POST \u002Fv1\u002Fauth\u002Flogout"," invalidates the session server-side and clears\nthe cookie. All concurrent sessions for the same user can be\ninvalidated via dashboard ",[38,509,510],{},"Account → Sessions → Sign out everywhere",[512,513,514],"style",{},"html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}",{"title":205,"searchDepth":244,"depth":244,"links":516},[517,518,521,526,527,528,529],{"id":25,"depth":233,"text":26},{"id":78,"depth":233,"text":79,"children":519},[520],{"id":169,"depth":244,"text":170},{"id":193,"depth":233,"text":194,"children":522},[523,524,525],{"id":197,"depth":244,"text":198},{"id":256,"depth":244,"text":257},{"id":360,"depth":244,"text":361},{"id":407,"depth":233,"text":408},{"id":434,"depth":233,"text":435},{"id":481,"depth":233,"text":482},{"id":500,"depth":233,"text":501},"Magic-link flow, token types, rotation",null,"md",{},true,"\u002Fapi\u002Fauth",{"title":5,"description":530},"api\u002Fauth",250,"yr79ou2D_X3DUWLSkYBcD39OGsbhNwIuQXPow5QZPIM",1779022954054]