tsidp is an experimental OIDC identity provider backed by Tailscale identities. It lets you authenticate your Runway apps with the Tailscale accounts already used by your team - no separate user database needed.
tsidp embeds tsnet and is itself a Tailscale node. Apps that support OpenID Connect (OIDC) can use it as their identity provider. After authenticating via tsidp, users receive standard OIDC tokens containing their Tailscale identity.
Prerequisites:
tag:tsidp created in your Tailscale ACL admin console
under Tags -> Create tag.
Set Tag name to tsidp, Tag owner to autogroup:admin
(or whichever group manages your infrastructure), and add a Note such as
“tsidp OIDC provider”.
The tag gives tsidp a service identity on your tailnet and is what the capability grant below uses to control who can access the tsidp admin UI.
A Tailscale auth key created with tag:tsidp
applied.
FROM ghcr.io/tailscale/tsidp:latest
USER 1001:1001
runway app create my-tsidp --persistence --plan scale-m-launch
runway app config set \
RUNWAY_TS_MANAGED=false \
TAILSCALE_USE_WIP_CODE=1 \
TS_AUTHKEY=tskey-auth-... \
TS_STATE_DIR=/data \
TS_HOSTNAME=my-tsidp \
TSIDP_ENABLE_STS=1
runway app route off
runway app deploy
tsidp manages its own Tailscale connection internally, so Runway’s Tailscale integration must
be disabled with RUNWAY_TS_MANAGED=false.
It also serves entirely over the Tailscale network interface, not a plain HTTP port, so
runway app route off is required. Once deployed, the admin UI is at
https://my-tsidp.tailnet-name.ts.net (but access is denied by default, see below).
TS_AUTHKEY is optional - if omitted, tsidp prints a registration link on first start.
tsidp uses Tailscale capability grants to control access. Add this to your Tailscale ACL:
"grants": [
{
"src": ["autogroup:member"],
"dst": ["tag:tsidp"],
"app": {
"tailscale.com/cap/tsidp": [{"allow_admin_ui": true}]
}
}
]
There are other capabilities one can enable or disable, including dynamic client registration, but only admin UI access is needed for this guide.
Open https://my-tsidp.tailnet-name.ts.net in a browser on your tailnet, register a new OIDC
client for the app you want to protect, and note the client_id and client_secret.
By default tsidp is only reachable within your tailnet. Tailscale Funnel makes tsidp’s OIDC endpoints publicly accessible over the internet.
Note: users logging in always need Tailscale running - tsidp’s /authorize endpoint is tailnet-only. What Funnel changes is whether the app needs to be on the tailnet. Without Funnel, the app must reach tsidp over the tailnet (via TS_AUTHKEY and proxy variables). With Funnel, tsidp’s token validation and discovery endpoints are reachable from anywhere. It also makes tsidp usable as an IdP for SaaS products and other services outside your tailnet.
Grant the funnel attribute to tag:tsidp in your Tailscale ACL:
"nodeAttrs": [
{
"target": ["tag:tsidp"],
"attr": ["funnel"]
}
]
Then enable Funnel on the tsidp app:
runway app config set -a my-tsidp TSIDP_USE_FUNNEL=1
runway app restart -a my-tsidp
Once enabled, my-tsidp.tailnet-name.ts.net is publicly resolvable and Tailscale proxies HTTPS traffic to it. Apps connecting to tsidp no longer need TS_AUTHKEY or the proxy variables.
If you have a custom app deployed on Runway and want to gate it behind Tailscale identity,
github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 is
the standard combination for Go. Below is a minimal but complete example.
Register an OIDC client in the tsidp admin UI using redirect URI https://myapp.pqapp.dev/callback,
then note the client_id and client_secret.
/login starts the OIDC flow, /callback handles the code exchange, /private is the protected
route. State is verified via a short-lived cookie. The session map is in-memory - sessions are
lost on restart.
// DO NOT USE AS-IS IN PRODUCTION: no locking, error handling omitted throughout.
package main
import (
"context"
"crypto/rand"
"fmt"
"log"
"net/http"
"os"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
oauth2Cfg oauth2.Config
verifier *oidc.IDTokenVerifier
sessions = map[string]string{} // would need locking for concurrent use
)
func main() {
provider, err := oidc.NewProvider(context.Background(), os.Getenv("OIDC_ISSUER"))
if err != nil {
log.Fatal(err)
}
verifier = provider.Verifier(&oidc.Config{ClientID: os.Getenv("OIDC_CLIENT_ID")})
oauth2Cfg = oauth2.Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email"},
}
http.HandleFunc("GET /", publicHandler)
http.HandleFunc("GET /private", requireAuth(privateHandler))
http.HandleFunc("GET /login", loginHandler)
http.HandleFunc("GET /callback", callbackHandler)
log.Println("WARNING: DO NOT USE IN PRODUCTION. This is an example only.")
log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil))
}
func publicHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "public page - try /private")
}
func privateHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello, %s\n", r.Context().Value("email"))
}
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
email, ok := sessions[cookie.Value]
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), "email", email)))
}
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
state := randomString(16)
cfg := oauth2Cfg
cfg.RedirectURL = "https://" + r.Host + "/callback"
http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: state, Path: "/", HttpOnly: true, Secure: true, MaxAge: 300})
http.Redirect(w, r, cfg.AuthCodeURL(state), http.StatusFound)
}
func callbackHandler(w http.ResponseWriter, r *http.Request) {
stateCookie, err := r.Cookie("oidc_state")
if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
cfg := oauth2Cfg
cfg.RedirectURL = "https://" + r.Host + "/callback"
token, err := cfg.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rawID, _ := token.Extra("id_token").(string) // error handling elided
idToken, err := verifier.Verify(r.Context(), rawID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var claims struct{ Email string `json:"email"` }
idToken.Claims(&claims) // error handling elided
sessionID := randomString(32)
sessions[sessionID] = claims.Email
http.SetCookie(w, &http.Cookie{Name: "session", Value: sessionID, Path: "/", HttpOnly: true, Secure: true})
http.Redirect(w, r, "/private", http.StatusFound)
}
func randomString(nBytes int) string {
b := make([]byte, nBytes)
rand.Read(b) // error handling elided
return fmt.Sprintf("%x", b)
}
Initialize the Go module, fetch dependencies, and commit everything before deploying:
runway app create myapp
go mod init myapp
go mod tidy
git add .
git commit -m "initial commit"
Deploy it pointing at your tsidp instance.
Create a Tailscale auth key for the app.
runway app config set \
TS_AUTHKEY=tskey-auth-... \
ALL_PROXY=socks5://localhost:1055/ \
HTTP_PROXY=http://localhost:1055/ \
HTTPS_PROXY=http://localhost:1055/ \
OIDC_ISSUER=https://my-tsidp.tailnet-name.ts.net \
OIDC_CLIENT_ID=<client-id> \
OIDC_CLIENT_SECRET=<client-secret>
runway app deploy
runway app config set \
OIDC_ISSUER=https://my-tsidp.tailnet-name.ts.net \
OIDC_CLIENT_ID=<client-id> \
OIDC_CLIENT_SECRET=<client-secret>
runway app deploy
Grafana is a metrics and dashboards platform that supports OIDC for login. Grafana’s OIDC integration is entirely env-var driven. Register an OIDC client in the tsidp admin UI using redirect URI https://my-grafana.pqapp.dev/login/generic_oauth, then note the client_id and client_secret.
FROM grafana/grafana
USER 472:472
Create a Tailscale auth key for my-grafana.
runway app create my-grafana --persistence --plan scale-m-launch
runway app config set \
TS_AUTHKEY=tskey-auth-... \
ALL_PROXY=socks5://localhost:1055/ \
HTTP_PROXY=http://localhost:1055/ \
HTTPS_PROXY=http://localhost:1055/ \
GF_PATHS_DATA=/data \
GF_AUTH_GENERIC_OAUTH_ENABLED=true \
GF_AUTH_GENERIC_OAUTH_NAME="Tailscale" \
GF_AUTH_GENERIC_OAUTH_CLIENT_ID=<client-id> \
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=<client-secret> \
GF_AUTH_GENERIC_OAUTH_SCOPES="openid email profile" \
GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://my-tsidp.tailnet-name.ts.net/authorize \
GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://my-tsidp.tailnet-name.ts.net/token \
GF_AUTH_GENERIC_OAUTH_API_URL=https://my-tsidp.tailnet-name.ts.net/userinfo \
GF_SERVER_ROOT_URL=https://my-grafana.pqapp.dev
runway app deploy
No Tailscale integration needed on the app - tsidp’s OIDC endpoints are publicly reachable. Users logging in still need Tailscale running.
runway app create my-grafana --persistence --plan scale-m-launch
runway app config set \
GF_PATHS_DATA=/data \
GF_AUTH_GENERIC_OAUTH_ENABLED=true \
GF_AUTH_GENERIC_OAUTH_NAME="Tailscale" \
GF_AUTH_GENERIC_OAUTH_CLIENT_ID=<client-id> \
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=<client-secret> \
GF_AUTH_GENERIC_OAUTH_SCOPES="openid email profile" \
GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://my-tsidp.tailnet-name.ts.net/authorize \
GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://my-tsidp.tailnet-name.ts.net/token \
GF_AUTH_GENERIC_OAUTH_API_URL=https://my-tsidp.tailnet-name.ts.net/userinfo \
GF_SERVER_ROOT_URL=https://my-grafana.pqapp.dev
runway app deploy