mTLS for digital signage: serving protected resources to screens
Screens have to reach pages and endpoints that sit behind a login they can never complete. Here is the server side of doing that with client certificates.
Almost every signage deployment eventually points a screen at something private, and it is rarely just one kind of thing.
A reporting dashboard above the shop floor. A JSON endpoint a page polls every thirty seconds for throughput figures. An internal web app somebody’s team built for dock scheduling. A page whose images and video come off an internal file server. A camera or telemetry feed from a system that was never meant to face the open network.
All of it sits behind a login, and the screen cannot log in. There is no keyboard, nobody is standing there, and whatever you give it has to keep working unattended for months.
Mutual TLS fits this shape well. The player holds a private key it never transmits and proves possession of it during the TLS handshake, so the screen authenticates itself before your application is involved. It works the same way whether the thing being protected is an HTML page, an API response, or a file.
This tutorial is about the server side, which is the half nobody writes down.
What you end up protecting
Signage deployments hit this in a few places, and every one of them takes the same mechanism.
The page the screen renders
A URL that loads on the player: a reporting dashboard, an internal web app, a status board your team built.
The endpoint behind dynamic content
A page or app on the screen fetching JSON on a timer, for throughput figures, incident counts, dock assignments, days since last incident.
This is the one that gets forgotten. The dashboard gets locked down and the endpoint feeding it quietly stays open, which leaves the data reachable even though the page in front of it is not.
The subresources that page loads
Images, video, fonts and stylesheets the page pulls from an internal host while it renders.
These are easy to miss because they are never typed into a signage dashboard: they are referenced from inside the page. Lock down the page and leave its asset host open and the content is still reachable. Lock down the asset host without giving it the same trust configuration and the page renders half-empty on the wall, which is the more common way to find out.
What client certificates give you
The credential never leaves the device
The private key is generated on the player and stays there. Nothing is transmitted that could be replayed, and there is no value sitting in a settings field to be copied.
Every screen is its own identity
Access logs, rate limits and audit trails name the individual screen rather than a shared viewer, so you can see which panel pulled which report and when.
Revocation is scoped to one screen
A player that is lost, replaced, or moved out of a secure area is cut off by removing its certificate from the trust store, or its name from the allowlist. Every other screen keeps working straight through that change: nothing to re-issue, nothing to push to the fleet, no window where half the estate has the new credential and half still has the old one. For a fleet spread across sites, that is often the difference between revoking a screen the day it goes missing and putting it off until the next maintenance slot.
Authentication happens below your application
By the time a request reaches your dashboard or API, the caller is already known. The application reads the identity from a header and renders, so an unattended screen needs nothing of its own at the application layer.
Network position stops mattering
A screen in a plant you run and a screen at a contractor-managed site authenticate identically. Access follows the device rather than the segment it happens to sit on.
It composes with what you already have
Terminate it at the edge or on the origin, keep SSO for the people reading the same dashboard, add IP rules on top if you want both. None of these get in each other’s way.
The identity is reusable
Once the server knows which screen is calling, it can serve that screen its own content. That turns out to be the part most deployments underuse, and it gets its own section below.
How the handshake changes
In ordinary TLS the server proves who it is and the client stays anonymous. In mutual TLS both sides present a certificate, and the server drops the connection if the client’s does not check out.
The mistake that undoes all of it
Worth reading twice, because it is easy to configure and looks correct.
Verifying that a client certificate is signed by a trusted authority is not the same as verifying that it is your screen.
If your players’ certificates come from a vendor’s certificate authority, every device that vendor has ever shipped carries a certificate signed by that same authority. A server that only checks the chain will accept a stranger’s player. You have authenticated the manufacturer’s customer list, not your fleet.
Pick one of these deliberately.
Trust the CA, then match the identity
Point the server at the CA certificate, and additionally check the presented certificate’s common name against screens you expect. The chain proves the certificate is real; the name proves it is yours. Both are needed.
Trust the device certificates directly
The alternative. Skip the CA and give the server only the specific screen certificates you want. Nothing else validates, so there is no name check to forget.
The first scales: commission a screen, it works. The second is tighter and needs no second check, at the cost of editing a bundle whenever the fleet changes. Ten screens in one plant, use the allowlist. Two hundred across sites with monthly additions, use the CA plus a name check.
Which is available depends on your player vendor. If they give you a CA certificate as well as per-device files, you can do either. If you only ever get per-device files, you are on the allowlist model whether you meant to be or not.
Building the trust store
For the allowlist model, concatenate the screen certificates:
cat plant1-line-a.pem plant1-line-b.pem warehouse-dock.pem > trusted-screens.pemOrder does not matter. Apache can read a directory instead, which avoids rebuilding the bundle each time.
NGINX in front of a dashboard
server {
listen 443 ssl;
server_name signage.grafana.internal.example.com;
ssl_certificate /etc/ssl/server.crt;
ssl_certificate_key /etc/ssl/server.key;
ssl_verify_client on;
ssl_client_certificate /etc/ssl/trusted-screens.pem;
# With a shared vendor CA, keep this at 1 so only certificates issued
# directly by that CA validate.
ssl_verify_depth 1;
location / {
# The identity check. Without it, any certificate that CA signed
# gets in, including players that are not yours.
if ($ssl_client_s_dn_cn !~ "^(plant1-line-a|plant1-line-b|warehouse-dock)$") {
return 403;
}
# Many dashboards can trust a header like this for auto-login,
# instead of showing a form the screen could never fill in.
proxy_set_header X-Screen-ID $ssl_client_s_dn_cn;
proxy_set_header X-WEBAUTH-USER signage-viewer;
proxy_pass http://grafana:3000;
}
}if inside a location is normally an NGINX trap, but return is one of the two directives that are safe there. For a longer fleet, a map keyed on $ssl_client_s_dn_cn reads better and stays out of the request path.
On the allowlist model, delete the if. The bundle is already the allowlist.
The API endpoint, same handshake
The dynamic-content case, where a page on the screen fetches JSON on a timer:
server {
listen 443 ssl;
server_name signage-api.internal.example.com;
ssl_certificate /etc/ssl/server.crt;
ssl_certificate_key /etc/ssl/server.key;
ssl_verify_client on;
ssl_client_certificate /etc/ssl/trusted-screens.pem;
ssl_verify_depth 1;
# Read-only surface for screens. Nothing here should accept a write.
location /api/v1/metrics {
limit_except GET { deny all; }
proxy_set_header X-Screen-ID $ssl_client_s_dn_cn;
proxy_pass http://metrics-api:8080;
}
}Two things worth doing on a signage API specifically. Restrict it to GET, because a screen has no business writing anything. And keep it to the fields the screen renders: a board showing throughput does not need the endpoint that returns every order line behind it.
Apache
<VirtualHost *:443>
ServerName signage.reports.internal.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/server.crt
SSLCertificateKeyFile /etc/ssl/server.key
SSLVerifyClient require
SSLVerifyDepth 1
SSLCACertificatePath "/etc/ssl/screens/"
<Location />
Require expr %{SSL_CLIENT_S_DN_CN} in {"plant1-line-a", "warehouse-dock"}
RequestHeader set X-Screen-ID "%{SSL_CLIENT_S_DN_CN}s"
ProxyPass "http://reports:8080/"
</Location>
</VirtualHost>SSLCACertificatePath wants a hashed directory, so run c_rehash /etc/ssl/screens/ after adding or removing a certificate. Forgetting is the usual reason a newly commissioned screen gets rejected.
HAProxy
frontend signage_in
bind *:443 ssl crt /etc/ssl/server-bundle.pem \
ca-file /etc/ssl/trusted-screens.pem verify required
acl known_screen ssl_c_s_dn(cn) -m reg ^(plant1-line-a|plant1-line-b|warehouse-dock)$
http-request deny unless known_screen
http-request set-header X-Screen-ID %{+Q}[ssl_c_s_dn(cn)]
default_backend dashboards
backend dashboards
server grafana1 127.0.0.1:3000Caddy
signage.internal.example.com {
tls /etc/ssl/server.crt /etc/ssl/server.key {
client_auth {
mode require_and_verify
trusted_ca_certs_file /etc/ssl/trusted-screens.pem
}
}
@unknown not vars {http.request.tls.client.subject_cn} plant1-line-a warehouse-dock
respond @unknown 403
reverse_proxy grafana:3000 {
header_up X-Screen-ID {http.request.tls.client.subject_cn}
}
}One URL, every screen its own numbers
This is where signage gets more out of mTLS than a typical client would.
Every example above forwards the certificate’s common name as X-Screen-ID. Your dashboard or API can branch on it, so a single content item assigned to the whole fleet renders each site’s own figures:
GET /api/v1/metrics
X-Screen-ID: warehouse-dockOne asset covering the whole fleet is also less to keep in step than a URL per location.
Two rules. Strip any inbound X-Screen-ID at the proxy, so nothing can set it for itself. And trust it only because the proxy verified the certificate first: the header is a convenience, not the authentication.
If your signage platform can already send screen metadata as request headers, prefer that for choosing content and keep the certificate for access control. They answer different questions.
Terminating at the edge instead
The handshake does not have to happen on your web server.
Cloudflare, an AWS Application Load Balancer, and most API gateways will terminate mTLS for you: upload the trust bundle, attach it to the listener, and unverified connections are dropped before they reach your network. Your dashboards keep serving plain HTTP behind it.
In an organisation of any size this is usually the better arrangement. The network team owns screen access in one place, and the people building reports never touch a TLS directive.
When people and screens share a dashboard
The question that always follows a rollout: the operations team still needs the report they have used for a year.
Split the hostname
reports.example.com keeps normal TLS and your SSO for people. signage.reports.example.com requires a client certificate and points at the same backend. Two doors, one room. This is nearly always right and takes ten minutes.
Make verification optional and enforce per path
When one hostname is a hard requirement:
ssl_verify_client optional;
ssl_client_certificate /etc/ssl/trusted-screens.pem;
location /reports {
proxy_pass http://dashboards;
}
location /signage-feed {
if ($ssl_client_verify != "SUCCESS") {
return 403;
}
proxy_set_header X-Screen-ID $ssl_client_s_dn_cn;
proxy_pass http://dashboards;
}optional means the server asks for a certificate and carries on without one, leaving you to check $ssl_client_verify on the paths that matter. The trade is that a new location block has to remember the check. The subdomain split fails closed instead, which is why it is the recommendation.
Operational things that bite later
Certificates expire
Find out the lifetime and who renews it before there are fifty screens. A fleet that goes blank on the same morning is a memorable way to learn this.
Decommissioning a screen is a config change
Allowlist model, drop its certificate and reload. CA model, drop it from the name list. It takes effect for that screen alone, which is the point, but it is not a button in a dashboard, so write down where that config lives and who can edit it before you need to do it in a hurry.
Test the failure, not the success
From a machine that is not a screen, confirm you are refused:
curl -sS -o /dev/null -w '%{http_code}\n' \
https://signage.grafana.internal.example.com/
curl -sS -o /dev/null -w '%{http_code}\n' \
--cert plant1-line-a.pem --key plant1-line-a.key \
https://signage.grafana.internal.example.com/The first should be refused. If it returns 200, verification is not actually on.
Know what a failure looks like on the wall
Players differ here. Some treat an unreachable page as a failed item and move straight on to the next thing in the playlist, which is quiet and easy to miss: the screen looks fine, it is just no longer showing that dashboard. Others leave the panel blank, which reads as broken hardware. Find out which yours does before a certificate expires, because it decides whether anyone notices.
Doing this with Screenly
Screenly players can present a device certificate, so everything above applies directly.
Open the screen’s Actions tab and choose Download certificate. The file is named after the device, like srly-y358c2nss7i40ly.pem, and you repeat it for each screen that needs access. The same tab has Download CA certificate, which is the authority that signed every device certificate. Then on the content item, open Advanced, turn on Allow sending client certificate, and save. That is the whole client side.
Because both are available, either trust model works. The device id in the filename is the certificate’s common name, so it is what you match on in the configs above and what arrives as X-Screen-ID.
The warning earlier in this article applies here with full force. Every Screenly device certificate is signed by the same Screenly authority, so a server that checks only the chain will accept a certificate from any Screenly player anywhere. If you go the CA route, match the common name as well.
This needs ScreenlyOS 26.2.0 or newer and is not available on Screenly Anywhere, which has no device certificate to present. Pages behind a login in the documentation covers the dashboard side in full.