1. Introduction §
In my self-hosted infrastructure, most services are only reachable through my VPN. But some of them have a feature to share something with people outside: Immich has share links for albums and photos, Nextcloud has share links for files and folders.
My self-hosted infrastructure with Incus OS
I wanted to add some extra security, but the people receiving a link should not have to install a VPN client or create an account, they must be able to click on it and see the content.
The usual way to do this is to expose the whole service on the Internet, and then you have to trust the login page, the API and every route of the application. I found a simple pattern with the reverse proxy caddy that only lets in requests carrying a valid share link, and answers a 404 to everything else. The service itself is used to check if the link is valid.
caddy official website
In this blog post, you will learn how to use caddy to expose only the "share link" part of a service, how it works, and what it brings for security.
2. The idea: forward authentication §
caddy has a directive named forward_auth. Before forwarding a request to the service, caddy sends a first request to another server (the "auth server"), and looks at the answer. If the answer is a success, the real request is forwarded, otherwise caddy returns the answer of the auth server to the client. This is usually used with software like Keycloak + oauth-proxy, to add a login page in front of an application.
caddy documentation: forward_auth directive
Here, the auth server is the service itself. Most applications have a route answering differently if a share key is valid or not, so we can ask the application "is this link valid?" before letting the request in.
The forward_auth directive is actually a shortcut for a reverse_proxy with a few options. I did not use forward_auth directly because I needed to rewrite the URL of the check request and to accept another status code than 2xx, so I wrote the long form with reverse_proxy and handle_response. This is also easier to understand, because everything is visible in the configuration.
caddy documentation: reverse_proxy, intercepting responses
The whole design is:
- the full service is reachable only through the VPN (or with an IP restriction), on a separate caddy instance (a single caddy server could work, but be sure to put IP restrictions on the internal vhost)
- a public caddy site exposes only the routes needed by a guest, matched very strictly
- for each request, caddy asks the service if the share key in the URL is valid
- if it is valid, the request is forwarded, if not, caddy answers 404
- every other URL gets the same 404, so a visitor cannot even know which software is behind
3. Example with Immich share links §
Immich is a photo and video library, I use it for my photos and videos. Browsing the library and uploading only works through the VPN, but I want to share albums with people who do not have direct access to it.
3.1. The check §
A share link in Immich looks like https://share.example.com/share/<key>, and then the web interface calls the API with ?key=<key> in the URL. I needed an API route that tells if a key is valid without doing anything. I found that requesting /api/users/me?key=<key> gives:
- 403 if the key is valid, because a share link is not allowed to read the user profile
- 401 if the key is invalid or expired
So here, a 403 means the key is valid. The password of a share link (if there is one) does not matter for this check, Immich still asks for it after.
This is a side effect of how Immich checks permissions, not a feature made for this purpose, so a future version of Immich could change it. If this ever changes, the share links will answer 404 and I will notice quickly.
In the caddy configuration, I wrote a snippet (a reusable block of configuration, called with import) taking the key as argument:
(share_key_immich) {
reverse_proxy http://10.0.0.10:2283 {
method GET
rewrite /api/users/me?key={args[0]}
@valid_key status 403
handle_response @valid_key {
vars share_key valid
}
handle_response {
respond 404
}
}
reverse_proxy http://10.0.0.10:2283
}
The first reverse_proxy is the check:
method GET and rewrite replace the method and the URL of the request sent to Immich, so whatever the visitor asked, Immich only receives GET /api/users/me?key=...
@valid_key status 403 matches the answer of Immich when the key is valid
- when the answer matches, the
handle_response block does not write any response (the vars line only sets an unused variable), so caddy continues with the next directive, which is the second reverse_proxy forwarding the real request
- in any other case, caddy answers 404 and the request never reaches Immich
3.2. The public site §
Then, the public site only accepts the URLs used by a share page. Everything is matched with strict regular expressions, so a guest can only reach a few API routes, and only with a key that looks like a key.
share.example.com {
# a visitor must not be able to send its own credentials
request_header -X-Immich-Share-Key
request_header -X-Immich-Share-Slug
request_header -X-Immich-User-Token
request_header -X-Immich-Session-Token
request_header -X-Api-Key
request_header -Authorization
# page of a share: /share/<key> and /share/<key>/photos/<asset>
@share_page path_regexp page ^/share/([A-Za-z0-9_-]+)(/photos/[0-9a-f-]{36})?/?$
# API routes used by a share page, with ?key=<key>
@api_read {
method GET
path_regexp ^/api/(shared-links/me|albums/[0-9a-f-]{36}(/map-markers)?|assets/[0-9a-f-]{36}(/original|/thumbnail|/video/playback|/video/stream/.+)?|timeline/buckets?)$
expression `{query.key}.matches("^[A-Za-z0-9_-]+$")`
}
@api_post {
method POST
path /api/shared-links/login /api/download/info /api/download/archive
expression `{query.key}.matches("^[A-Za-z0-9_-]+$")`
}
# requests without a key, and static files of the web interface
@share_config {
method GET
path /api/server/config /api/server/features
header Referer https://share.example.com/share/*
}
@share_static {
method GET
path /_app/*
header Referer https://share.example.com/*
}
handle @share_page {
import share_key_immich {re.page.1}
}
handle @api_read {
import share_key_immich {query.key}
}
handle @api_post {
import share_key_immich {query.key}
}
handle @share_config {
reverse_proxy http://10.0.0.10:2283
}
handle @share_static {
reverse_proxy http://10.0.0.10:2283
}
handle {
respond 404
}
}
The request_header -... lines remove all the headers Immich uses for authentication, this way the share key in the URL is the only way to be authenticated on this site.
The key is taken from the path for the share page ({re.page.1} is the first group of the regular expression), and from the key parameter of the query for the API calls ({query.key}).
The server configuration and the JavaScript and CSS files of the interface do not carry any key, so I only allow them with a Referer header coming from the site. A client can put anything in this header, it is not a security measure, it just avoids answering to random scanners. These routes only give the static files of the interface and some general server information, which is ok to expose.
To find which routes a share page needs, the easiest way is to open a share link with the developer tools of your browser, look at the requests in the "Network" tab, and allow them one by one until the page works. This is not ideal, but it works.
4. Example with Nextcloud share links §
I am running a Nextcloud instance through the VPN only, but I want others to be able to open the files and folders I occasionally share.
4.1. The check §
A Nextcloud share link looks like https://cloud.example.com/s/<token>, where the token is a random string identifying the share. This time there is no need for a strange API trick, the password page of a share /s/<token>/authenticate/showShare answers 200 if the token is valid and 404 if it is not. It does not ask for the password, and it does not display the share, so this is a good route for the check and only a 200 lets the request in.
If the share is protected by a password, this is not a problem because the check only tells if the token exists, Nextcloud will still ask for the password.
This is the same pattern, so the snippet is almost the same as for Immich:
(share_token_nextcloud) {
reverse_proxy http://10.0.0.30:19000 {
method GET
rewrite /s/{args[0]}/authenticate/showShare
@valid_token status 200
handle_response @valid_token {
vars share_token valid
}
handle_response {
respond 404
}
}
reverse_proxy http://10.0.0.30:19000
}
4.2. The public site §
The public site allows the share page, the download and preview of shared files, the password page, and the public WebDAV of the share. WebDAV is used by Nextcloud to list the files of a shared folder, and to upload files into it.
cloud.example.com {
request_header -Authorization
# page of a share, download, direct preview
@share_page {
method GET
path_regexp page ^(?:/index\.php)?/s/([A-Za-z0-9-]+)(/download(/[^/]+)?|/preview)?/?$
}
# page and form of the password of a protected share
@share_auth {
method GET POST
path_regexp auth ^(?:/index\.php)?/s/([A-Za-z0-9-]+)/authenticate/[A-Za-z]+$
}
@share_preview {
method GET
path_regexp preview ^(?:/index\.php)?/apps/files_sharing/publicpreview/([A-Za-z0-9-]+)$
}
# public WebDAV of the share: listing, download, upload (simple or in chunks)
# Nextcloud applies the permissions of the link
@share_dav {
method GET HEAD PROPFIND PUT MKCOL MOVE COPY DELETE
path_regexp dav ^/public\.php/dav/(?:files|uploads)/([A-Za-z0-9-]+)(/.*)?$
}
# requests without a token and static files of the interface (including the PDF viewer), from a page of the site
# I found this regex in a configuration example to serve static data separately
@static {
method GET
path_regexp ^(?:/(?:core|dist|apps/[a-z0-9_]+)/.+\.(?:js|mjs|css|map|svg|png|gif|jpg|webp|ico|woff2?|ttf|json|ftl|bcmap|icc|pfb|wasm)|(?:/index\.php)?/apps/files_pdfviewer/|(?:/index\.php)?/apps/theming/(?:favicon|icon|image|img|manifest|theme|css)(?:/.*)?|(?:/index\.php)?/(?:csrftoken|core/mimeicon)|/index\.php/apps/files/preview-service-worker\.js)$
header Referer https://cloud.example.com/*
}
handle @share_page {
import share_token_nextcloud {re.page.1}
}
handle @share_auth {
import share_token_nextcloud {re.auth.1}
}
handle @share_preview {
import share_token_nextcloud {re.preview.1}
}
handle @share_dav {
import share_token_nextcloud {re.dav.1}
}
handle @static {
reverse_proxy http://10.0.0.30:19000
}
handle {
respond 404
}
}
Nextcloud URLs can start with /index.php or not, this is why the regular expressions have an optional (?:/index\.php)? at the beginning.
The WebDAV route allows methods that write or delete files and Nextcloud only accepts them if the share link allows it, for example a read only share still refuses an upload. You can not replace Nextcloud authorization with caddy here.
The long list of extensions for static files is needed by the PDF viewer, and the Referer check is the same as for Immich.
All requests will answer 404, except if they target a known working share URL or that a request has a working URL in its Referer (this is the trick to have other resources part of the request to be downloaded).
5. What it brings for security §
An attacker scanning the Internet only gets 404 replies. The software behind is not visible, so bots looking for a specific application will not find it. If there is a vulnerability in the login page, the admin API or the upload feature, it cannot be reached from the Internet, only the code needed to display a share is exposed, and only to someone who already has a valid link. This is not entirely security by obscurity here, the main goal is not to hide Nextcloud or Immich, but prevent anyone to trigger the backend code if they do not have a legitimate need.
A recent example is php2shell, the name given to two critical flaws in PHP itself (CVE-2026-109618 and CVE-2026-109619), which could allow remote code execution without authentication. At the time of writing, it is still not clear which part of PHP is vulnerable. Nextcloud is written in PHP, so a public Nextcloud is a good target. With this setup, almost every request without a valid share link gets a 404 from caddy and never reaches PHP, it is much harder to even send the exploit. A few routes without a token still go through PHP (like /csrftoken or the theming icons), so it does not fully protect against this kind of flaw.
This only works if the public site stays strict. If you also allow the normal login page of the service, the protection does not bring much anymore, because the login page is not a secret, anyone can reach it, and with it most of the application code and its vulnerabilities. Same thing for an API route allowed "just in case". On my public sites, a route either checks a share token with the service, or gives harmless content like the static files of the interface, everything else stays on the VPN.
Guessing a link is not realistic, share keys in Immich and Nextcloud are long and random, and an invalid key gives the same 404 as any other URL.
For the people I share things with, nothing changes. They receive a link and it opens in their browser, they do not need to install anything or create an account. On my side, I keep using the full services through the VPN, with a dedicated domain name.
6. Limitations §
Someone with a valid link can still reach the routes allowed on the public site, so a vulnerability in these routes would still be exploitable, but by far fewer people.
The routes depend on how the application works. An update may add or rename a route, and the share page will break until I update the configuration.
There are also smaller things:
- each allowed request triggers a second request to the service for the check
- if someone forwards a share link, the new person has access too, but this is how share links work anyway
Finding which routes are needed takes some time at first, the browser developer tools help a lot for this. Writing the regular expressions and checking that the links still work correctly is a bit time consuming.
7. Conclusion §
This setup is simple once you have the few lines of caddy configuration per service, and it does the job, the services are not visible on the Internet anymore, except for the people I explicitly gave a link to.
So far, I am happy with this implementation. I originally had a simpler version filtering unused routes, but it still gave too much access to the actual API answering content.
This pattern can be used for any service that has a way to tell if a share link or a token is valid. If you self-host a service only for a small set of users, but sometimes need to share something with someone outside, it is worth a try.
8. Going further §
If you want a real login page in front of a service, you can use forward_auth with an authentication server like Keycloak with oauth-proxy, this is what this directive was made for, but this is a different need than sharing a link with someone.