Security
Bridges are device access, so every bridge route requires one authorization policy,
AppDeviceBridgePolicies.Bridges. You decide what it allows; the default keeps them on the device.
Who may call the bridges
Section titled “Who may call the bridges”| Build | Default |
|---|---|
| Release | A caller on this device only: a loopback connection, reaching the server by a loopback name, with no Origin or a loopback one. With Shiny.AppDeviceBridge.WebView, it must also be the app’s own WebView, holding the launch session. |
| Debug | Any caller not arriving through a tunnel. A browser on your machine, curl, a script, another device on the network. |
- Launch session (WebView). Binding to loopback keeps other machines out but not other apps: on Android any
app can connect to
127.0.0.1. Each launch generates a 256-bit token that the WebView’s first navigation trades for anHttpOnly,SameSite=Strictcookie. The WebView host adds that cookie to the default bridge policy, so another app on the device gets a401. The web app’s own files need it too (403without). - Host header. A request to the bridges,
_hostor the web app under a name the server doesn’t answer to gets421, whoever sent it. Loopback names and IP addresses are accepted, plus any name inAllowedHosts. That’s what stops DNS rebinding: a hostile site pointing its own name at the device arrives under that name. - Origin header. A browser call from another site running on this device carries that site’s
Origin, and the default policy refuses it. - Releases. A release must pass five checks before it’s served: signature, app id, a version newer than the installed one, host compatibility, then size and hash. An archive without its entry document is refused.
Deciding for yourself
Section titled “Deciding for yourself”AuthorizeBridges replaces the default policy. It’s a Shiny.Net.HttpServer policy, so it can use any scheme
you add:
builder.Services.AddShinyHttpServer(http =>{ http.AddAuthentication().AddApiKey(k => k.AddKey(key, "kiosk", "kiosk"));
// This device's callers, or anyone presenting the kiosk key. http.AddAppDeviceBridge(bridge => bridge.Configure(o => o.AuthorizeBridges(p => p.RequireAssertion(ctx => BridgeCallers.IsOnDevice(ctx.HttpContext) || ctx.User.IsInRole("kiosk")))));}, autoStart: false);- The replacement is the whole rule. The launch session is no longer added, and neither is the debug allowance. Whatever the policy allows can reach the device.
BridgeCallershas the checks the default uses:IsOnDevice(HttpContext),IsLocalConnection(HttpContext),IsLocal(IPAddress),IsLoopbackHost(host). AskIsLocalConnection, notIsLocal, whether a request came from this device: a tunnel delivers from loopback.- The WebView’s session is a scheme. It authenticates as
WebAppSessionAuthenticationHandler.SchemeNamewith theappdevicebridge:sessionclaim, sop.RequireClaim(WebAppSessionAuthenticationHandler.SessionClaim)keeps the page in a policy of your own.
Serving the network
Section titled “Serving the network”Binding the server past loopback doesn’t open the bridges. The default policy still refuses anyone off the device in a release build.
builder.Services.AddShinyHttpServer(http =>{ http.Options.Address = IPAddress.Any; // reachable from the network http.AddAppDeviceBridge(bridge => bridge.Configure(o => o.AllowedHosts.Add("kiosk.local"))); // an mDNS name you control, besides IP addresses}, autoStart: false);
builder.UseAppDeviceBridge(bridge => { … }, webApp => webApp.ServeWebAppRemotely = true); // optional: the app's own pages tooA browser on the device itself — the same machine opening http://127.0.0.1:5780/ — has no launch session, so in a
release build it gets 403 for the pages too. ServeWebAppLocally = true serves it the pages; the bridges still
follow the bridge policy, and a tunneled caller never counts as local.
- The session never leaves the device.
/_host/startanswers403over the network, and the session cookie authenticates nothing when replayed from another machine. - The dev server is never relayed. Remote callers get the installed build, never the proxy to
dotnet watch. - Mind the body limit.
MaxFileWriteBytes(256 MB) raises the server’s request body limit for every route, since the server checks it before routing. Once the server is reachable from the network, that includes your own endpoints and anonymous callers. Lower it when the bridges don’t need large files, and check sizes in your own upload endpoints. - Open a bridge remotely with a policy. Use
AuthorizeBridgeswith a credential, as above. A route-level allowlist is one assertion away:ctx.HttpContext.Request.Path.StartsWithSegments("/_bridge/files").
Through a tunnel
Section titled “Through a tunnel”Shiny.AppDeviceBridge.Tunnel gives the server a public HTTPS address — an SSH reverse forward to a public host such as
Pinggy, in managed code, so it works on phones as well as desktops. The app turns it on and off while it runs; there
are no routes for it, so where that switch lives, and who may flip it, is the app’s decision.
builder.UseAppDeviceBridge(bridge => bridge.AddTunnel()); // or in the bridge delegate next to the web app's options
public sealed class Sharing(AppDeviceBridgeTunnel tunnel){ public Task<Uri?> OnAsync(string? pinggyToken) => tunnel.StartAsync(pinggyToken); // null, with LastError, if it failed public Task OffAsync() => tunnel.StopAsync();}- A tunnel caller is a remote caller. A tunnel hands the server its requests from the local end of the
forward, so every one arrives from
127.0.0.1— with headers written by someone on the internet. It is neverIsOnDevice, never holds the WebView’s session, isn’t let in byAllowAnyCallerInDebug, and gets the web app’s pages only withServeWebAppRemotely(never withServeWebAppLocally). Your own endpoints decide for themselves, as for any remote caller. - Only the tunnel’s own name. A tunneled request must carry the tunnel’s current public host (or a name in
AllowedHosts, for a custom domain in front of it). One claiming127.0.0.1orlocalhostgets421. - Bind to the address.
PublicUrlandStateraisePropertyChanged, on a background thread. A free tunnel reconnects at a new address, and the old host stops being accepted the moment it goes. - It doesn’t start the listener. Tunneled requests go straight into the server’s pipeline, so a server stopped because sharing on the local network is off can still be reached through a tunnel that’s on.
- Your own tunnel. Register an
IAppDeviceBridgeTunnelwhosePublicUrlis the address, and the server accepts that host the same way.
Your own endpoints
Section titled “Your own endpoints”Your API sits beside the web app and the bridges on the same server, configured the Shiny.Net.HttpServer way —
middleware, raw routes, source-generated [Route] classes, authentication:
using Shiny.Net.HttpServer;using Shiny.Net.HttpServer.Security; // AllowAnonymous, RequireAuthorization, AddApiKey
builder.Services.AddShinyHttpServer(http =>{ http.AddAuthentication().AddApiKey(k => k.AddKey(key, "kiosk", "admin")); http.AddAuthorization(a => { a.SetFallbackPolicy(p => p.RequireAuthenticatedUser()); a.AddPolicy("admin", p => p.RequireRole("admin")); });
http.Configure(server => { server.UseAuthentication(); server.UseAuthorization();
server.MapOrderEndpoints(); // [Route("/api/orders")] server.MapGet("/api/health", ctx => …).AllowAnonymous(); server.MapGet("/api/admin", ctx => …).RequireAuthorization("admin"); server.MapGet("/api/draft", ctx => …).RequireAuthorization(WebAppPolicies.Session); });}, autoStart: false);- The WebView is one more scheme. Its session authenticates like any other, so the page calls your endpoints with
no extra setup once your pipeline has
UseAuthentication.WebAppPolicies.Sessionaccepts the WebView and nothing else, however valid another credential is. - Bridges keep their own policy. Your endpoints’ fallback and policies don’t reach them, so a key that opens
/api/adminopens nothing on the device unlessAuthorizeBridgessays so. An endpoint you map under the bridge prefix is guarded as a bridge. - Where you map it is where it’s served.
BasePathmoves the web app and the bridges, not your endpoints. - Every
AddAuthorizationcall applies, yours and the bridges’ alike (Shiny.Net.HttpServer 1.2).
With the WebView host, a path under BasePath that matches none of your endpoints is the web app’s, and without the
WebView’s session that’s a 403, so a caller outside the page can’t probe which routes exist.


