I've been building on Azure Static Web Apps for a while now, and until this week I'd never actually thought hard about kiosk mode. It sounds like a UI setting. Full screen, hide the browser chrome, lock the tablet to one app. I didn't expect it to break authentication in a way that took real digging to understand properly.
Quick note if you're not deep in IT yourself: I started out as an accountant, CPA and everything, so if some of this reads like sorcery, that's exactly how it looked to me not long ago too. There are plain-English bits along the way. And fair warning, a few of the concepts in here were new to me when I started, so you'll see me say so as they come up.
The project is a sign-in kiosk for bus drivers at depots. A driver walks up to an unattended tablet, punches in a 4-digit PIN, and clocks on or off shift. That is the entire interaction. No driver has an Entra login of their own, and none of them need one, the PIN is the whole interface. The tablet itself is signed into Entra as a shared kiosk identity, running React and TypeScript on the front end, with Azure Functions behind it exposed through SWA's managed API, and SWA's own built-in Entra ID authentication, what Microsoft calls Easy Auth, protecting the whole thing. Not MSAL.js, not a custom OIDC flow, just the config-driven auth SWA gives you out of the box, pointed directly at our own Entra tenant.
Every so often, a driver would walk up to a kiosk that had been sitting idle overnight or over a long gap between shifts, and get a generic error: "Unable to connect. Please try again." Retrying did nothing. The tablet stayed dead until someone physically reloaded or rebooted it.
A Generic Error That Looked Like Three Different Problems
My first instinct was that this was an infrastructure problem. Flaky depot wifi, a cold Azure Function, a database having a moment. The trouble is that "Unable to connect" is a catch-all covering three completely different failure domains, front end, API layer, database layer, and there was no way to tell which one was actually failing just from what the driver saw on a dead screen at a depot I wasn't standing in front of.
Before I even knew what the real cause was, the priority was making failures debuggable remotely. These are unattended tablets in the field, I can't exactly pop open DevTools on a bus depot tablet from my desk, and a driver isn't going to describe an HTTP error to me over the phone even if they wanted to. So I built a retry wrapper around the API calls that would retry a couple of times on timeout or network errors, show the driver a friendly "taking longer than usual" message during retries instead of a dead screen, and on final failure surface a verbose error string instead of a generic one, tagged with the endpoint and distinguishing an HTTP error from a timeout from a plain network failure.
That change didn't fix anything by itself. But it's what surfaced the actual signal. The verbose error was consistently [NETWORK] Failed to fetch, and only on kiosks that had been idle a long time. That pointed away from "slow API" and toward something stranger: the request was never getting a response at all, because something was killing it before it ever reached the server.
When our MSP's cloud engineer first looked at this, his assumption, reasonably, was that it was my API. So I checked the Azure Function logs for the affected window. Nothing. No log entry at all, not slow, not errored, just nothing. The request was leaving the browser but never reaching the function, which ruled out the API and the database in one go. That's the moment he went away and came back with the actual explanation, because "the request never arrives at the function" and "the Entra session died" turned out to be the same fact from two different sides: SWA's own edge was handling the dead session before the Function ever had a say.
The fix I built first was for a completely different reason, making errors legible for remote debugging. It turned out to be the instrument that caught the real problem. Making errors more visible pays off even when you don't yet know what you're looking for.
A Cookie With a Fixed Lifetime and No Way Back
I didn't land on the actual cause by myself. What came back from him a bit later was the real explanation: reloading a dead kiosk fixed it instantly, which meant the Entra SSO session itself had to be timing out rather than anything downstream. SWA's built-in Entra auth issues a cookie fixed at roughly 8 hours, there's no /.auth/refresh endpoint to extend it, and the fix is a response override plus a client-side check that forces a real page navigation back through /.auth/login/aad when the session is gone.
I was grateful he took the time, and my next reaction was still reservation rather than deploying it straight away. He'd used an AI assistant to check current details before sending it over, and that's where my habit kicks in: I get a lot of people forwarding me something an AI told them, and more often than not it's wrong in a specific way. It doesn't have the actual architecture in front of it, so it sounds confident while missing the one detail that matters. Before touching staticwebapp.config.json on the strength of a forwarded message, I wanted the claim in a primary source. He'd linked the GitHub issue behind it, and I read the whole thread myself rather than stopping at the summary.
Checking DevTools on an affected kiosk confirmed it firsthand too: the SWA-issued session cookie, StaticWebAppsAuthCookie, had expired. That cookie has a fixed lifetime of roughly 8 hours when you use SWA's built-in Easy Auth, and there's no refresh endpoint for it. Unlike Azure App Service's Easy Auth, which exposes /.auth/refresh, SWA's built-in auth has nothing equivalent. Once the cookie dies, the only way back is a full round trip through /.auth/login/aad.
Every /api/* route on this app requires authenticated. That check happens at SWA's own edge, before the request is ever routed to the Function app, which is exactly why the logs were empty earlier: once the cookie died, SWA judged every API call unauthenticated and handled it itself, the Function never got invoked to log anything at all. From the front end's point of view, all of that looked identical to a network failure, because the app had never been written to expect or handle a 401 specially.
I wonder: would just making that route anonymous have "fixed" this? It occurred to me while writing this up, and it's worth saying out loud rather than pretending it didn't, because the reasoning behind it is genuinely tempting. If /api/* didn't require authenticated at all, the 401 disappears, no dead cookie, no watchdog needed, one line changed in a config file, and I get my evening back. The instinct that makes it feel safe is "the API still needs a token anyway, all this really does is let more people see the button on the front end, they'd still have to be signed in to actually use it."
That instinct is wrong here. This app uses managed Functions, provisioned through the SWA resource itself, and Azure's own documentation is explicit that a managed function doesn't repeat any authentication or role check of its own, access control is defined entirely by the allowedRoles rule in staticwebapp.config.json. A separately deployed "bring your own Functions" app could add independent auth on top, a different trust boundary entirely, but ours doesn't. So that route restriction isn't a convenience layer in front of a real check further in. It is the authorization boundary. Make it anonymous without a replacement, and the API is reachable by anyone who can send a request to the URL, a plain curl, no browser, no PIN pad.
The "you'd still need to be logged in to click the button" part is the actual misconception, and it's a common one: a button hidden or disabled in the UI is a suggestion, not an enforcement mechanism. Nothing stops a request from being sent straight to the API without ever touching the front end. If the only thing standing between "anyone on the internet" and this API is a line in a config file saying who is allowed to route to it, then that line is the entire lock, and anonymous means there's no lock.
"It isn't the API being slow, not the database being down, not the wifi dropping. It is the browser's own session with SWA silently expiring in the background, with nothing in the app watching for it."
And there's nobody physically present at these kiosks who has, or could use, an AAD login. Drivers only ever see a PIN pad. The standard failure mode for an expired web session (showing a login page and letting the person click sign in) doesn't exist here. There is no one to click anything.
An interactive human
Fixed-lifetime cookie, no refresh token, redirect-based login flow. A reasonable default for a normal web app where a person is sitting at the keyboard when the session dies.
Nobody at all
An unattended tablet with a PIN pad. No AAD login belongs to anyone standing there. The failure has to be recovered without a human in the loop, or not at all.
Reading that thread was worth it. Issue #761 was opened in March 2022 and is still open today, confirming the cookie expires in 8 hours and can't be extended through /.auth/refresh the way App Service's can. Read the thread, not just the title. Other teams have tried iframe-based silent refresh and cross-subdomain cookie tricks, one commenter eventually gave up and moved to MSAL entirely, and the issue has stayed open for more than three years without an announced platform fix. One early comment claims /.auth/refresh is "supported recently," but it's unverified whether that commenter speaks for Microsoft, and every report after it, including Microsoft's own Q&A answers, says the endpoint still isn't supported for SWA.
Then I fell down a side alley and found Issue #1480, which stopped me for a bit. A cookie replayed outside the browser, copied into Postman, say, used to keep authenticating well past its 8 hours. Microsoft fixed it in late 2024, so it isn't a live concern. But it took several months from a community member's report, with a working reproduction, to a confirmed fix.
That one bothers me, and I don't think that's an overreaction. The whole point of an 8-hour expiry is that a stolen session stops being useful after 8 hours. For a stretch there, it didn't. And my answer to "what if someone walks off with the tablet" is, in large part, that the cookie dies. I was leaning on that limit without ever having checked whether it held.
To be clear about what I'm not saying: I've no idea whether this was ever exploited, and I'm not suggesting it was. The public record doesn't say whether Microsoft already knew internally before the report came in. What I can see is the gap, several months where a control a lot of production apps were quietly depending on, including this one, wasn't doing what it said on the tin. Managed platforms are a good trade, mostly. This is the part of the trade you don't get a say in.
What's left, though, isn't a "forever token" problem. It's a physical access one. Anyone with the tablet in hand for up to 8 hours inherits whatever the kiosk identity can do. That's a kiosk-hardening question, not a flaw in the redirect mechanism.
Which is why the identity is scoped narrowly on purpose. The kiosk signs in with a dedicated account, not a staff member's normal login, locked down by our MSP with its own MFA and sign-in configuration. Worth being precise about what that restricts, though: anyone with an Entra account in our tenant can authenticate. The security group controls authorization, not authentication, /api/GetRoles only grants the app role the routes check when the signed-in identity belongs to that group. None of it touches the 8-hour cookie, that limit sits below all of it. But someone walking off with this tablet inherits an account built for exactly one kiosk, not a real person's access.
What's still genuinely open is the thing #761 is asking for: a fixed 8-hour lifetime with no way to extend or refresh it.
Not Shipping the First Answer That Sounded Confident
The message from our MSP already came with a proposed fix attached: a config override, a client-side watchdog, and an API wrapper that caught 401s and 403s the same way. That became my starting draft, not my final answer. I've started doing this for anything auth-related, put the same context in front of more than one AI and compare, rather than shipping the first version that sounds confident.
The first review confirmed the diagnosis and handed the same code back with no new scrutiny. All correct on the facts, but it carried forward an unverified assumption, that the underlying token dies at 60 to 90 minutes rather than the full 8 hours, and built a guessed 45-minute poll interval on top of it without ever suggesting I measure the real number.
The second review agreed on the facts and then caught three things the first one missed: that "silent" isn't guaranteed, because Conditional Access or MFA can force a human step at any time; that fetch() follows redirects and never sees the 401 at all; and that 401 and 403 need completely different handling. Two of those would have shipped a fix that looked complete and quietly didn't work. That's the whole argument for a second opinion, the first one wasn't wrong, it just wasn't looking.
The Bug Where the Obvious Fix Doesn't Get You There
The natural instinct for "kick off a new login" is to call fetch('/.auth/login/aad'). This doesn't work, and figuring out exactly why sent me back to that same Issue #761 thread, where the original report says almost this exact thing: converting a 401 into a redirect and then hitting it through a background request "leads to CORS errors and makes error handling in the application difficult."
The mechanism: /.auth/login/aad starts on the same origin as the app, but immediately 302s onward into Entra's domain to do the sign-in check, and that hop is cross-origin. fetch() defaults to CORS mode, and Entra's login endpoints don't send back the headers that would let a cross-origin fetch read the response. So the promise rejects with a generic network error, which shows up in DevTools as a CORS failure and in application code as an unhelpful "failed to fetch." Any path that tries to reach Entra through a background fetch hits the same wall, whether that's SWA's own responseOverrides redirect chain or a direct call.
Which is almost certainly what the verbose logging was surfacing at the very start of this investigation: [NETWORK] Failed to fetch, consistently, only on long-idle kiosks. At the time it just looked like something dying quietly. It was a background fetch that could never succeed once it crossed into Entra's domain.
Now, full transparency: I've written that paragraph like someone who knew all of it a fortnight ago, and I didn't. CORS was genuinely new to me. I'm a data engineer, I'm comfortable with APIs and status codes and how requests move between systems, but the browser's own security model, same-origin policy, preflight requests, which headers have to come back for a response to be readable, that was not in my toolkit. I got here by asking the MSP's cloud engineer questions, then asking an AI the follow-up questions I didn't want to waste his time on, then going and reading the actual docs to check that what I'd been told held up.
So the box below isn't me explaining down to anyone. It's the version I had to build for myself before the fix made sense.
Think of every website as its own house, with its own address. The JavaScript running in your browser is like an assistant who lives at your app's house and can freely grab anything inside it. If that assistant quietly tries to slip a request to a different house, say, Microsoft's login page, and read back what comes from inside without you walking over there yourself, the browser's default rule is simple: no, an assistant from one house doesn't get to read what happens inside a different one. That default is called the same-origin policy, and it exists so a script on some random website can't secretly reach into your bank's site or your email while you're logged into them elsewhere. It's a safety feature, not a bug.
CORS is the exception process, the thing that lets a house override that default and say "visitors from this specific address are welcome to read what I send back." The request can technically still go out, but unless the house left a note granting permission to that specific visitor, the browser refuses to hand the response back to the assistant, which from your app's point of view looks identical to nothing happening at all.
Microsoft's login pages don't leave that note for arbitrary apps, they're built for a person to be sent there directly, not for a background script to sneak a request in and read the result. So when the code tried to quietly fetch the login page in the background, the response came back but the browser withheld it. The fix: stop sneaking, send the browser to the front door for real, a genuine page navigation, instead of trying to read the result through a side window.
Whether it throws a CORS error or resolves with something unusable, the browsing context never navigates, so Entra never gets the chance to set the fresh cookie the app needs. The fetch call can't do the one thing that matters.
The fix is a genuine top-level navigation, not a fetch:
export function reauthenticate() {
if (reauthInProgress) return; // never overlap or loop
reauthInProgress = true;
// preserve exactly where the kiosk was, so it lands back
// there instead of bouncing to "/"
const returnTo = window.location.pathname
+ window.location.search
+ window.location.hash;
const loginUrl = `/.auth/login/aad?post_login_redirect_uri=${encodeURIComponent(returnTo)}`;
// replace(), not href = , so a dead page never sits in history
window.location.replace(loginUrl);
}
location.replace() instead of location.href =, deliberately, so the expired page never sits in browser history for something to accidentally navigate back into.
Because the kiosk's Entra account already has an active SSO session with Microsoft, this round trip is invisible in the normal case, two redirects, no credential prompt. But invisible isn't the same as guaranteed. Conditional Access sign-in-frequency rules, an MFA requirement, or a device compliance check can all turn that redirect into a screen that needs a human, at a kiosk with no human who can act on it. The honest way to describe this is automatic reauthentication using an existing SSO session, not silent renewal.
Wait, isn't this exactly the "forever token" problem?
My first reaction to the whole design was suspicion. If the front end can keep quietly redirecting itself back into a signed-in state whenever the cookie dies, forever, without a human entering a credential again after the first login, isn't that a forever token wearing a redirect as a costume? I didn't want to ship it without understanding why it's fine, and my first pass at "why it's fine" turned out to be too optimistic.
What I had right: the code never manufactures or extends a credential. Every reauthentication is a genuine round trip through Entra. What I had wrong: I assumed that round trip meant Conditional Access got a fresh check every time. Not necessarily. Microsoft's documentation on Conditional Access session controls is explicit that Sign-in Frequency is a maximum lifetime, not a keep-alive, evaluated only when something requests a new token. Without an explicit Sign-in Frequency policy on the kiosk identity, Entra's browser-side SSO session can silently satisfy every redirect the kiosk makes, for as long as that underlying session stays valid, which by default runs a good deal longer than 8 hours.
So the honest version: the redirect pattern doesn't create a forever token, but it doesn't eliminate one either. It moves the question one layer up, to Entra's own SSO session and whatever Sign-in Frequency policy is or isn't attached. The fix isn't redesigning the redirect, it's making sure that policy is actually attached, plus keeping the identity narrowly scoped so a long-running session doesn't grant much.
Two Different Bugs Wearing the Same Costume
Two things handle this. The config sends a dead session back through login and a genuine permissions failure somewhere else entirely:
"responseOverrides": {
"401": {
"statusCode": 302,
"redirect": "/.auth/login/aad?post_login_redirect_uri=.referrer"
},
"403": {
"rewrite": "/unauthorized.html",
"statusCode": 403
}
}
.referrer is a special SWA-substituted token, not a literal placeholder, confirmed by a Microsoft team member on issue #628. SWA fills it in server-side from the original request path, so the tablet lands back where it was instead of bouncing to the root route. Query parameters and fragments aren't supported, which doesn't bite here since this is a single-page app, but it's worth knowing if you ever add deep links.
Then there's a wrapper called callApi() in front of every request, and its main job is playing referee between two failures that look almost identical from the outside but need completely opposite handling.
| Status | What it actually means | What the wrapper does |
|---|---|---|
| 401 Unauthorized | The session cookie is dead. This is a session problem. | Reauth Calls reauthenticate(), throws, stops. Retrying the same request is pointless, the page is about to navigate away. |
| 403 Forbidden | Authenticated, but missing the role for this route, for example GetRoles returned nothing, or the account left the security group. This is a permissions problem, not a session problem. |
No reauth Throws a distinct NotAuthorizedError. Re-logging in wouldn't add a role that isn't there, so triggering reauth here would just build an infinite silent-login loop for an account that will never get in. |
| Anything unexpected | Thanks to the 401โ302 override above, a dead session hitting /api/* doesn't always come back as a clean 401. It can arrive as a partly-resolved redirect, or as a response that isn't JSON where JSON was expected. The shape varies; the cause is the same. |
Reauth Checks the response's content-type rather than trusting the status code alone. Anything that isn't JSON where JSON was expected is treated as a dead session too, as a defensive catch-all for whichever shape the override actually produced. |
Conflating 401 and 403 is an easy thing to skip past when the whole focus is "just get the session refresh working." Skipping it creates a new bug rather than fixing the old one: a legitimately forbidden account would loop forever trying to silently reauthenticate into a role it's never going to get.
Don't Wait for a Failed API Call to Find Out
The core design decision is not to wait for an API call to fail before discovering the session is dead. SWA exposes its own session probe, /.auth/me, documented to always return a 200, even for a dead session, with { "clientPrincipal": null } in the body, or a populated object when the session is alive. That flat 200 is what makes it safe to poll in the background: it never triggers the 401โ302 override, it's just a plain status check. Don't confuse it with /api/GetRoles, which is a rolesSource callback that runs once, server-side, during login to decide which roles get stamped onto the principal. It has nothing to do with whether the current session is still valid.
One thing this watchdog doesn't do is get the kiosk its first session. That part isn't automated and doesn't need to be, provisioning a tablet is a one-time IT step where someone signs into the kiosk identity interactively, establishing the initial browser-level SSO session and the first SWA cookie. Everything here is about keeping that session alive afterwards, not eliminating the need for it.
A tunable poll interval, not a guessed one
checkAuthentication() hits /.auth/me on an interval. I left the number as a named, exported constant rather than hard-coding a guess, because the honest answer is I haven't yet confirmed the real cookie expiry behaviour on a physical kiosk in DevTools. Five minutes is a safe placeholder, frequent enough to catch expiry well before the 8-hour limit, cheap enough not to matter either way.
Also check on visibilitychange, not just the interval
Tablets throttle or fully suspend background JS timers when the screen isn't active, a kiosk sitting overnight with the display dimmed can't rely on setInterval alone to keep firing on schedule. Re-checking on document.visibilitychange catches the case where the screen wakes up right as the timer would have fired anyway.
A guard against overlapping reauth attempts
The poll interval and a visibilitychange event can fire in quick succession. A simple reauthInProgress flag stops two navigations from stepping on each other, and a 10-second stall timeout backstops the case where the redirect doesn't come back at all, most likely Conditional Access now requiring interactive input nobody at the kiosk can give, resetting after a 30-second backoff so a transient hiccup doesn't permanently strand the tablet until someone drives out to reboot it.
Lessons Worth Carrying Forward
It works, for what that's worth. I deployed the fix, left a tablet running overnight untouched, went to bed and tried not to think about it. Next morning it was still going. No reboot, no 6am panic, nobody driving out to a depot. One run through one expiry cycle isn't proof it survives every scenario, but it's the exact one the whole thing was built for.
The apparent problem (a generic connection error) and the actual problem (an 8-hour cookie dying silently on unattended hardware) were almost entirely unrelated at first glance. The verbose-error work I did first, for a different reason, is what surfaced enough signal to point at the real cause. Make errors legible before you know what you're looking for.
This is also the gap between the failure mode a feature was designed around and the one that shows up in production. Standard session expiry assumes a human who can click "log in again." Kiosks break that assumption completely, and the fix has to be structurally different, background polling and automated recovery, not just a login page. SWA's Easy Auth is built for the interactive-human case, which is a reasonable default, but it needs this kind of workaround to hold up on hardware nobody is standing in front of.
I didn't tune the poll interval to a measured number. I left it as a named constant with a placeholder and a comment saying why, which felt more honest than shipping a confident-looking 45-minute figure borrowed from an assumption nobody had verified against a real kiosk.
And I didn't find the root cause on my own. Someone at our MSP did the first real bit of pattern-matching and pointed me at a primary source instead of just handing me an answer to trust. My part was the reservation, the verification, and turning a rough draft into something that holds up on a tablet nobody is standing in front of. Both halves mattered, and saying which is which beats pretending it was a solo effort.
Which goes for the rest of it too. A good chunk of this was learned in the doing: conversations with the cloud engineer, follow-up questions to an AI, then checking both against Microsoft's own documentation before I trusted anything enough to deploy it. I'm not a tokens-and-CORS expert and I'd rather say so than perform one. I understand the mechanism well enough to reason about it and well enough to know which parts I'd want a real identity specialist to check. That gap is exactly why I went looking for primary sources instead of taking the first confident-sounding answer, including my own.
I don't think that's a weakness worth hiding, either. Coming at this from data engineering rather than software engineering meant I had to actually understand each piece before I could use it, which is slower, but it's also why I caught the things I caught.
Anyway. I hope you're still following me here, because reading this back it is relentlessly IT-heavy, and I say that as the person who wrote it. If you made it this far and the takeaway is just "the tablet stopped working because a cookie got old, and Maha spent a suspicious amount of his week on it," honestly, that's the gist. You're not missing much.
And if you're the sort of person who did follow all of it: the accountant who wrote this still can't quite believe he spends his days on session cookies instead of depreciation schedules. Both involve things quietly expiring when nobody's looking, so perhaps it was always heading this way.
