Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

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.

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 an HttpOnly, SameSite=Strict cookie. The WebView host adds that cookie to the default bridge policy, so another app on the device gets a 401. The web app’s own files need it too (403 without).
  • Host header. A request to the bridges, _host or the web app under a name the server doesn’t answer to gets 421, whoever sent it. Loopback names and IP addresses are accepted, plus any name in AllowedHosts. 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.

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.
  • BridgeCallers has the checks the default uses: IsOnDevice(HttpContext), IsLocalConnection(HttpContext), IsLocal(IPAddress), IsLoopbackHost(host). Ask IsLocalConnection, not IsLocal, whether a request came from this device: a tunnel delivers from loopback.
  • The WebView’s session is a scheme. It authenticates as WebAppSessionAuthenticationHandler.SchemeName with the appdevicebridge:session claim, so p.RequireClaim(WebAppSessionAuthenticationHandler.SessionClaim) keeps the page in a policy of your own.

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 too

A 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/start answers 403 over 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 AuthorizeBridges with a credential, as above. A route-level allowlist is one assertion away: ctx.HttpContext.Request.Path.StartsWithSegments("/_bridge/files").

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 never IsOnDevice, never holds the WebView’s session, isn’t let in by AllowAnyCallerInDebug, and gets the web app’s pages only with ServeWebAppRemotely (never with ServeWebAppLocally). 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 claiming 127.0.0.1 or localhost gets 421.
  • Bind to the address. PublicUrl and State raise PropertyChanged, 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 IAppDeviceBridgeTunnel whose PublicUrl is the address, and the server accepts that host the same way.

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.Session accepts 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/admin opens nothing on the device unless AuthorizeBridges says so. An endpoint you map under the bridge prefix is guarded as a bridge.
  • Where you map it is where it’s served. BasePath moves the web app and the bridges, not your endpoints.
  • Every AddAuthorization call 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.