Safeguard
Supply Chain Security•

Homelab Single Sign-On: Self-Hosted OIDC, MFA and Proxy Auth

Run one open source OIDC identity provider, sign apps in with the code flow and PKCE, gate apps without logins at the proxy, and make passkeys the MFA.

Safeguard Security Team
52 min read

Homelab single sign-on means one self-hosted identity provider (IdP) checks who you are, with a password plus a passkey or security key, and every app trusts its answer. Apps that speak OpenID Connect use the authorization code flow with PKCE. Apps without a login sit behind a reverse proxy that asks the IdP first.

This guide goes one level deeper than a general introduction to single sign-on: the protocol at the level a homelabber needs, the categories of open source identity projects, where forward authentication fails quietly, and a container deployment you can reproduce. It builds on our guides to building a small homelab, whether it is safe to self-host apps on the internet and running your own certificate authority at home, because SSO needs working internal TLS first. Every protocol rule below comes from the OpenID Foundation specifications and the IETF RFCs, every authenticator rule from NIST SP 800-63B-4 and the W3C WebAuthn Level 3 Recommendation, and every configuration key from the open source projects' own documentation. Commercial identity products appear only as categories, and no project here is ranked against another.

Key takeaways

  • Single sign-on pays off once you run more than four or five web apps or more than one person uses them: one strong login, one place for MFA, one off switch.
  • The price is concentration: one account opens everything, the IdP becomes a single point of failure, and a parent domain session cookie travels to every app.
  • Use the OAuth 2.0 authorization code flow with PKCE using the S256 method for every client, including server side apps. RFC 9700 (January 2025) makes PKCE mandatory for public clients, recommends it for confidential ones, requires exact string matching of redirect URIs, and says the password grant must not be used.
  • An ID token tells the app who signed in; an access token is for an API and is never proof of identity.
  • Forward authentication at the reverse proxy suits apps with no login of their own, but only if nothing can reach the app except through the proxy, and it breaks API and mobile clients.
  • Make WebAuthn the second factor: a passkey or a hardware security key. NIST SP 800-63B-4 (July 2025) says OTP codes and anything typed by hand are not phishing resistant, and requires a phishing resistant option to be offered at AAL2. Keep TOTP only as a fallback.
  • Keep the IdP's admin console off the internet, use a separate admin account, back up its database and keys, and keep a break glass path for the day it is down.

Why does a homelab need single sign-on?

A homelab starts with one or two web apps, each with its own login. A year later it might run a media server, a photo library, a document archive, a git forge, a dashboard, a home automation hub, a network controller and several admin consoles, each with its own account database, password rules, MFA support and session lifetime.

Three problems appear at that point.

Credential sprawl. Every app holds a password hash of its own, some weak, some never rate limited. The weakest app sets the security of every reused password, and households reuse passwords.

Uneven MFA. A few apps support TOTP, fewer support WebAuthn, and many support neither. You end up protecting the media server with a passkey and the network controller with nothing.

No single off switch. When a laptop is stolen or a guest leaves, you visit every app to disable their account, and one always gets missed.

Single sign-on moves authentication to one place: one password hash, one MFA policy, one session, one account to revoke. Apps either speak OpenID Connect to the IdP or sit behind a proxy that asks the IdP for them. If you run more than four or five web apps, share them, or expose any of them outside your network, it is worth the setup time.

What are the risks of one login for everything?

Centralising authentication concentrates risk, and you should name the risks before you accept them.

Blast radius. One stolen session or one phished password now opens every app at once, not one. This is the main reason MFA must be phishing resistant on the IdP. A TOTP code typed into a convincing fake login page is relayed to the real one in seconds, and NIST SP 800-63B-4 says exactly that about manually entered authenticator outputs.

Single point of failure. If the IdP container fails to start after an update, or its database corrupts, every app behind it is locked. That includes the hypervisor console you would use to fix the IdP, unless you planned a way around it. The section on securing the IdP covers the break glass path.

Cookie scope. Forward authentication portals usually set their session cookie on your parent domain so that it reaches every subdomain. That means the browser sends the portal's session cookie to every app behind it, including any app that is compromised or simply logs request headers. Authelia's Caddy integration guide includes an example that removes its session cookie from requests before they reach the backend for this reason. Native OIDC apps do not have this problem, because each keeps its own cookie on its own hostname.

Trust in the proxy. With forward authentication, the app trusts a Remote-User header from the proxy. Anyone who reaches the app another way can set it themselves.

None of these is a reason to avoid SSO; each is a reason to configure it carefully.

How do OAuth 2.0 and OpenID Connect work?

OAuth 2.0 (RFC 6749, October 2012) is a framework for delegated authorization: it lets a client get an access token to call an API on a user's behalf. It was not designed to tell the client who the user is. OpenID Connect Core 1.0 (final in February 2014; the current text incorporates errata set 2, dated 15 December 2023) adds that missing identity layer on top. In OpenID Connect terms, your homelab IdP is the OpenID Provider (OP), and each app that signs users in is a Relying Party (RP), which is also an OAuth client.

Here is the vocabulary you need, and nothing more.

TermWhat it means in a homelab
IssuerThe IdP's base URL, for example https://auth.home.example.com. Every token names it in the iss claim.
ClientAn app registered with the IdP. It has a client_id and, if it can keep a secret on a server, a client_secret.
Confidential clientA server side app that can keep a secret: a dashboard, a git forge, a proxy.
Public clientA client that cannot keep a secret: a single page app, a mobile app, a command line tool.
Redirect URIThe exact URL on the app where the IdP sends the browser back with an authorization code.
ScopeWhat the client asks for. openid makes it an OpenID Connect request; profile, email, address and phone request standard claims.
ID tokenA signed JSON Web Token (JWT) that says who signed in, for which client, and when. It is for the app.
Access tokenA credential the client presents to an API. It is for the API, not proof of identity.
Refresh tokenA longer lived credential the client uses to get new access tokens without sending the user back to the IdP.
Discovery documentJSON at /.well-known/openid-configuration that lists the IdP's endpoints, keys and supported features.

The authorization code flow, step by step

OpenID Connect Core defines three flows: authorization code, implicit and hybrid. For a homelab, use the authorization code flow for every app. RFC 9700, the OAuth 2.0 Security Best Current Practice published as BCP 240 in January 2025, says clients should not use the implicit grant or other response types that return access tokens in the browser redirect, because those tokens leak and can be replayed.

The flow works like this.

  1. You open https://grafana.home.example.com. The app sees no session and redirects your browser to the IdP's authorization endpoint with response_type=code, its client_id, its redirect_uri, scope=openid profile email, a random state, a random nonce, and a PKCE code_challenge with code_challenge_method=S256.
  2. The IdP checks that the client_id exists and that the redirect_uri exactly matches one it has registered for that client. OpenID Connect Core requires an exact match using simple string comparison.
  3. The IdP authenticates you: password, then passkey or security key. If you already have a session at the IdP, it may skip this step, which is what makes single sign-on feel single.
  4. The IdP redirects your browser back to the redirect_uri with a short lived, one time code and the same state.
  5. The app checks that state matches what it stored, then calls the IdP's token endpoint directly, server to server, with the code, its client credentials and the PKCE code_verifier.
  6. The IdP checks the code, the client and that the verifier hashes to the challenge from step 1. It returns an ID token, an access token and, if allowed, a refresh token.
  7. The app validates the ID token, creates its own session cookie, and you are in.

The key property is that tokens never travel through the browser's address bar. Only the short lived code does, and a stolen code is useless without the verifier.

What PKCE adds, with a worked example

Proof Key for Code Exchange (RFC 7636, September 2015) was written for mobile apps, where a malicious app on the same phone could register the same custom URL scheme and intercept the authorization code. RFC 9700 extends the advice to every kind of client, including web apps: PKCE protects against authorization code injection and, as a side effect, against cross site request forgery on the redirect endpoint.

The mechanism is simple. Before each login, the client generates a random code_verifier between 43 and 128 characters. It sends a hash of it, the code_challenge, in the authorization request. At the token endpoint it sends the verifier itself. An attacker who steals the code from a log or a redirect does not have the verifier, so the token endpoint refuses them.

RFC 7636 Appendix B gives a test vector you can reproduce on any machine with OpenSSL:

# Test vector from RFC 7636 Appendix B
VERIFIER='dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
printf '%s' "$VERIFIER" \
  | openssl dgst -sha256 -binary \
  | openssl base64 -A | tr '+/' '-_' | tr -d '='
# Expected output:
# E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

The S256 method is the SHA-256 hash of the verifier, base64url encoded without padding. The older plain method sends the verifier itself as the challenge, so anyone who can read the authorization request learns the secret. RFC 7636 says a client capable of S256 must use it and must not downgrade to plain after trying S256. RFC 9700 adds that S256 is currently the only method that does not expose the verifier, that authorization servers must support PKCE, and that a server must reject a token request carrying a code_verifier if the original request had no code_challenge, which blocks a downgrade.

Two practical consequences follow for a homelab:

  • Turn on PKCE for every client, not only public ones, and pin the method to S256 where the IdP lets you.
  • Check the IdP's discovery document for code_challenge_methods_supported. RFC 9700 recommends publishing it so that clients can detect support.

ID tokens, and what an app must check

An ID token is a JWT with a header, a payload of claims and a signature. The claims that matter are:

  • iss: the issuer. It must exactly match the issuer the app was configured with.
  • sub: a stable identifier for the user at this IdP. Use it, not the email address, as the account key.
  • aud: the audience. It must contain the app's client_id.
  • exp: expiry time. The current time must be before it.
  • iat: when the token was issued.
  • nonce: the value the app sent in the authorization request, echoed back to bind the token to that login attempt.
  • azp: the authorized party, relevant when the token has several audiences.

OpenID Connect Core section 3.1.3.7 lists the full validation steps. Use an app's built in OIDC support or a maintained library rather than parsing tokens yourself; your job is to configure the right issuer URL and client ID so those checks work. Never accept an access token as a login: its audience may be another service, and an app that does so can be signed into with a token issued to a different client.

Scopes and claims

OpenID Connect requests must include the openid scope. The standard extra scopes are profile (name, preferred username and similar claims), email (email and email_verified), address and phone. Many self-hosted IdPs add a groups scope that puts group membership in a claim, which apps such as dashboards and git forges use to map users to roles.

Ask for the least you need. A dashboard that maps an admins group to a role needs openid profile email groups, not offline_access, which asks for a refresh token that can outlive the user's session.

Redirect URIs: the setting that matters most

RFC 9700 section 2.1 says authorization servers must compare redirect URIs against the registered values with exact string matching. The one exception is the port number on loopback redirect URIs used by native apps, which RFC 8252 (October 2017) describes. Exact matching stops an attacker from asking the IdP to send your code to a URL they control, or to an open redirector on your own domain that forwards it on.

Some IdPs still let an administrator register wildcards. Keycloak's server administration guide, for example, allows a wildcard at the end of a valid redirect URI and allows a full *, and warns not to use the full wildcard in production. Authelia's client documentation says redirect URIs are case sensitive and must match exactly or authorization fails. Treat any wildcard as a debugging convenience to remove before an app goes live.

Discovery, signing keys and the issuer

Every OpenID Provider publishes a discovery document at /.well-known/openid-configuration (OpenID Connect Discovery 1.0). It lists the authorization, token and userinfo endpoints, the jwks_uri where the IdP's public signing keys live, supported scopes, response types, signing algorithms and, increasingly, PKCE methods and logout endpoints. Most apps need only the issuer URL and fetch the rest.

Two consequences matter. The issuer URL is in every token, so changing the IdP's hostname later breaks every client and, as we will see, every passkey. And apps must reach the discovery and JWKS URLs over HTTPS with a certificate they trust, so with a private CA every app container needs your root. If an app talks to more than one IdP, RFC 9207 (March 2022) adds an iss parameter to the authorization response to stop mix-up attacks.

Logout is harder than login

Single sign-on gives you single login. Single logout is a separate problem, because each app keeps its own session after the IdP hands it a token. The OpenID Foundation defines several logout mechanisms:

  • RP-Initiated Logout 1.0 (final, 12 September 2022): the app sends the browser to the IdP's end_session_endpoint, usually with an id_token_hint and a registered post_logout_redirect_uri, so that logging out of an app also ends the IdP session.
  • Back-Channel Logout 1.0 (final, errata set 1 dated 15 December 2023): the IdP calls each app's registered logout URI server to server with a signed logout token, so that ending the IdP session ends the app sessions too.
  • Front-Channel Logout 1.0: the IdP loads each app's logout URL in a hidden frame in the browser. Browser restrictions on third party cookies make this less reliable than it once was.

Many homelab apps implement none of these, so logging out of the IdP does not end an app's own session. The practical answer is short app sessions, short token lifetimes, and testing logout explicitly, which the walkthrough does.

A short timeline of the specifications

DateSpecificationWhy it matters here
May 2011RFC 6238, TOTPThe six digit codes most authenticator apps generate, on a 30 second time step by default
October 2012RFC 6749, OAuth 2.0The authorization framework everything else builds on
February 2014OpenID Connect Core 1.0 finalAdds ID tokens and the identity layer
September 2015RFC 7636, PKCEBinds the authorization code to the client that started the login
October 2017RFC 8252, OAuth for native appsLoopback redirect URIs and the external browser rule for CLI and desktop clients
March 2022RFC 9207, issuer identificationThe iss parameter that defeats mix-up attacks
September 2022OpenID Connect RP-Initiated Logout 1.0 finalStandard way for an app to end the IdP session
December 2023OpenID Connect Core 1.0 errata set 2; Back-Channel Logout errata set 1Current published texts
January 2025RFC 9700 (BCP 240), OAuth 2.0 Security Best Current PracticePKCE everywhere, exact redirect matching, no implicit or password grants
July 2025NIST SP 800-63B-4Phishing resistance, syncable authenticators, session timeouts
25 August 2026W3C WebAuthn Level 3 RecommendationCurrent passkey and security key API

What kinds of self-hosted identity providers exist?

Open source identity software for homelabs falls into a few categories. Projects overlap, and many deployments combine two categories, such as a directory plus an IdP, or an IdP plus a proxy. The examples below are open source projects described from their own documentation, listed alphabetically within each category, and not ranked.

CategoryWhat it doesOpen source examplesGood fitWatch out for
Full featured identity and access managementMultiple tenants, user federation, admin UI, OIDC, SAML, configurable authentication flows, MFA, brute force detectionKeycloak; authentikMany apps, several users, SAML apps, custom login flowsMore moving parts, a database to run, a large admin surface to protect
Authentication portal with an OIDC providerA login portal for forward auth plus an OpenID Connect provider, configured from filesAutheliaMostly reverse proxied apps, configuration kept in gitYou still need a user store: a file or an LDAP directory
Identity platform with its own directoryAccounts, groups, passkeys, OIDC and sometimes RADIUS or Unix login in one serviceKanidmCentralising both web logins and machine accountsA newer model to learn; check app compatibility per protocol
Lightweight directoryA simplified LDAP server that holds users and groups for other services to querylldapApps that only speak LDAP, and as the user store behind a portalNot a full LDAP server by design, and not an IdP on its own
Authenticating reverse proxySits in front of an app, runs the OIDC flow against an IdP, and passes identity to the appoauth2-proxyApps with no login, per app OIDC sessions, Kubernetes ingressDoes not hold users; depends on an IdP

A few notes from the projects' documentation:

  • Keycloak's server administration guide covers PKCE, brute force detection, recovery codes, passkeys, OTP and configurable authentication flows.
  • authentik's proxy provider supports three modes: a full proxy to one upstream, forward auth for a single application, and forward auth at domain level. Its documentation notes that domain level forward auth cannot enforce different authorization rules per application.
  • Authelia documents an OpenID Connect 1.0 provider with per client PKCE settings, WebAuthn and TOTP second factors, deny, bypass, one_factor and two_factor access policies, and integrations for Caddy, Traefik, nginx and other proxies.
  • Kanidm's README lists passkeys (WebAuthn) including attested passkeys, an OAuth2 and OIDC provider, and RADIUS for network and VPN authentication.
  • lldap describes itself as an opinionated, simplified LDAP interface for authentication and says plainly that it is not a full LDAP server, intentionally. It includes a web UI where users can change their details or request a password reset.
  • oauth2-proxy authenticates users against an OIDC provider and supports PKCE, recommending S256.

How do you choose a category?

Ask four questions.

  1. Which protocols do your apps speak? List each app and mark whether it supports OIDC, SAML, LDAP, trusted header auth or nothing. If several apps need SAML, you need an IdP that issues SAML. If most apps speak nothing, a portal with forward auth will do most of the work.
  2. Where should users live? A file is enough for one or two people. A directory makes sense once several services need to share group membership or when a non web service (a file server, a VPN) needs to authenticate users.
  3. How do you want to manage configuration? Some projects are configured mainly through an admin UI and database, others mainly through files you can keep in version control. Neither is better, but the answer changes how you back up and how you review changes.
  4. How much do you want to run? A full platform usually means an application server plus a database; a portal can run on an embedded SQLite file. Fewer parts means fewer ways to break.

How does forward authentication work at a reverse proxy?

Forward authentication, also called auth request or external auth, lets a reverse proxy ask an authentication service whether to let each request through. The app behind the proxy never sees a login and does not need to support one.

The sequence is the same across proxies:

  1. A request arrives at the proxy for whoami.home.example.com.
  2. Before forwarding it, the proxy makes a subrequest to the auth service, carrying the original method, host and URI in X-Forwarded-* headers and the browser's cookies.
  3. If the auth service answers with a 2xx status, the proxy forwards the original request to the app, copying identity headers such as Remote-User, Remote-Groups, Remote-Email and Remote-Name from the auth response into the request.
  4. If the auth service answers with anything else, typically a redirect to the login portal or a 401, the proxy returns that to the browser instead.

The three common open source proxies document this behaviour directly:

  • Caddy's forward_auth directive sends a GET request to the auth upstream with the URI rewritten, adds X-Forwarded-Method and X-Forwarded-Uri, grants access on a 2xx and copies the listed headers, and otherwise copies the auth service's response back to the client.
  • Traefik's ForwardAuth middleware grants access when the auth service answers with a 2XX code and copies the headers listed in authResponseHeaders. Its current reference marks the trustForwardHeader option as deprecated and warns at startup if it is not set explicitly.
  • nginx's auth_request module allows access when the subrequest returns 2xx, denies it on 401 or 403, and treats any other code as an error.

What are the limits of forward authentication?

Forward auth is useful, and it has clear limits.

The app trusts whatever reaches it. The app sees identity only as a request header. If the app's port is reachable on the LAN, from another container on the same network, or through a second proxy route, anyone can connect directly and send their own Remote-User header. Put the app on a network only the proxy can reach, publish no ports for it, and make sure the proxy removes any client supplied identity headers before the auth check adds the real ones.

Non browser clients break. A mobile app, a desktop sync client or a script calling the app's API cannot follow a redirect to a login page and complete a passkey ceremony. You either add bypass rules for API paths, which re-opens those paths, or use the app's own token mechanism for API access. Every bypass rule is a hole you must reason about.

Authorization is coarse. The portal decides who may reach a hostname or path. It does not know about objects inside the app. Domain level forward auth in some IdPs cannot even distinguish between apps under the same parent domain, as authentik's documentation notes.

Logout is split, and the cookie travels. Portal logout ends forward auth access at once, but an app that builds its own session from the header may keep it, and the portal's parent domain cookie still reaches every app.

Misconfiguration fails open. If a proxy route is missing the forward auth directive, the app is simply public. Authelia's documentation warns that mistakes in its advanced proxy configuration can lead to authentication bypass, and it publishes a guide for validating forwarded authentication. Test every route after every proxy change.

Where an app supports OIDC well, native OIDC is usually better: the app validates a signed token, keeps its own cookie and maps groups to roles itself.

How should MFA work in a homelab?

The short answer is WebAuthn first, TOTP as a fallback, and never SMS for the IdP.

What does NIST SP 800-63B-4 say about authenticator strength?

NIST SP 800-63B-4, published in July 2025, sets authentication assurance levels (AALs) for US federal systems. Its reasoning about authenticators applies to any login that protects many services.

AuthenticatorPhishing resistant under SP 800-63B-4?Notes
Password aloneNoMinimum 15 characters when used as a single factor; minimum 8 when used as part of MFA
Look-up secrets (recovery codes)NoGood for recovery, not for daily use
Out-of-band codes (SMS, voice, push with a typed code)NoUse of the phone network is restricted
OTP devices and apps (TOTP)NoManual entry of an output is not phishing resistant
Cryptographic authenticator using WebAuthn (passkey, security key)Yes, when it meets section 3.2.5Binds the response to the site's origin
Syncable passkeyYes, at AAL2Not allowed at AAL3 because the key is exportable
Hardware security key with a non-exportable keyYesMeets the AAL3 key requirement

The points that matter for a homelab:

  • Phishing resistance comes from cryptography bound to the site. SP 800-63B-4 defines phishing resistance as the ability of the protocol to stop secrets and authenticator outputs reaching an impostor verifier without relying on the user's vigilance, and states that authenticators involving manual entry of an output, such as OTP, must not be considered phishing resistant.
  • At AAL2, verifiers shall offer at least one phishing resistant option. Your IdP should do the same.
  • Syncable authenticators are acceptable at AAL2. Appendix B allows passkeys that sync through a cloud account at AAL2 and excludes them from AAL3 because their keys are exportable. It also describes the WebAuthn Backup Eligible and Backup State flags that let a verifier see whether a credential can sync or has synced.
  • Session limits. At AAL2, the overall reauthentication timeout should be no more than 24 hours and the inactivity timeout no more than 1 hour. At AAL3, the overall timeout shall be no more than 12 hours, and inactivity should be no more than 15 minutes.
  • Passwords. No composition rules, no forced periodic changes unless there is evidence of compromise, and permit at least 64 characters.
  • Rate limiting. Limit consecutive failed attempts on one authenticator to no more than 100. The document explains that the figure balances guessing a six digit OTP against the cost of account recovery.

For a homelab IdP, aim for the AAL2 timeouts or tighter, and use a hardware security key for the admin account.

WebAuthn passkeys and security keys

WebAuthn is a W3C browser API for creating and using public key credentials. Level 3 became a W3C Recommendation on 25 August 2026. When you register, the authenticator (a security key, a phone, or a laptop's platform authenticator) creates a new key pair scoped to a Relying Party ID, keeps the private key, and gives the site the public key. When you sign in, the browser passes the site's origin into the signed challenge, and the authenticator signs only for the matching Relying Party ID. A look-alike domain gets nothing it can use.

Three WebAuthn properties shape an IdP deployment:

  • The Relying Party ID is a domain. The specification lets the caller set the RP ID to the current origin's effective domain or a registrable domain suffix of it. Credentials registered at auth.home.example.com are bound to that RP ID. If you later move the IdP to a different domain, every passkey must be registered again. Choose the IdP's hostname once.
  • WebAuthn needs a secure context. The portal must be served over HTTPS with a certificate the browser trusts, which is another reason to settle your certificates before your SSO.
  • User verification. A passkey can require a PIN or biometric on the authenticator (user verification) in addition to touch (user presence). Requiring user verification makes a passkey a multi-factor credential on its own.

Passkeys are either device-bound, living on a security key or a platform authenticator that does not export them, or synced through the user's platform account to their other devices. Synced passkeys are convenient and still phishing resistant, but depend on that account. For the admin account, use a device-bound key plus a spare kept elsewhere. Some IdPs let you refuse credentials that report themselves as backup eligible; Authelia's prohibit_backup_eligibility option does exactly that.

TOTP as a fallback

TOTP (RFC 6238) derives a six digit code from a shared secret and the current time, on a 30 second step by default. It works on any phone, needs no browser support and is simple to back up. It is also phishable and its shared secret sits on the server. Keep TOTP for situations where WebAuthn cannot work, such as an old device, and consider disabling it entirely once every user has two WebAuthn authenticators registered. Never use SMS for the IdP.

Recovery codes

Recovery codes are look-up secrets: single use random strings you print or store offline. They are not phishing resistant, but they are the difference between a lost phone and a lost homelab. Generate them for every account, store them on paper or in an encrypted offline file, and treat their use as an event worth logging.

How do you secure the identity provider itself?

The IdP now holds the keys to everything. Treat it with more care than any app behind it.

Keep the admin console off the internet. If you expose the login portal externally, expose only the user facing paths. Serve administrative interfaces on a separate hostname or path that the reverse proxy allows only from your management network or a VPN. Keycloak, for example, documents a hostname-admin option for a separate admin console hostname, and warns that it does not stop the admin REST API being reached through the main hostname; you have to block that at the reverse proxy.

Separate admin from daily use. Use one account for everyday app access and a separate IdP administrator account with a hardware security key and a short session, so a stolen everyday session cannot change the IdP's configuration.

TLS everywhere, including inside. WebAuthn and secure cookies need HTTPS at the browser, and the app to IdP leg carries client secrets and tokens, so give it HTTPS too.

Protect the signing keys and secrets. The IdP signs ID tokens with a private key; anyone with that key can mint a valid login for any user at any app. Store it, the session secret, the storage encryption key and every client secret as files with tight permissions or as container secrets, not in environment variables committed to git. Authelia, for example, documents _FILE variants of its secret settings for this purpose.

Back up the right things, and test restores. Back up the configuration, signing keys, user store and the database that holds registered WebAuthn credentials and TOTP secrets. Encrypt the backup and test a restore before you depend on it.

Rate limit logins. Turn on the IdP's brute force protection. Authelia calls this regulation, with max_retries, find_time and ban_time settings; Keycloak calls it brute force detection.

Keep lifetimes short. Short authorization codes (a minute), access and ID tokens of an hour or less, refresh tokens only for apps that need them, and an IdP session that matches the AAL2 timeouts or tighter.

Update on a cadence. An IdP parses untrusted input. Follow the project's security advisories, pin image tags, read the changelog before upgrading, and keep the previous image for rollback.

Log and watch. Ship authentication logs off the IdP host and watch for repeated failures, logins from new networks and changes to clients or admin accounts.

Plan the break glass path. Decide now how you get in when the IdP is down. Keep a local administrator account on the hypervisor, the router and the storage system that does not depend on SSO, with a long random password and a hardware key where supported, stored offline. Make sure the console you would use to repair the IdP is one of those. Test the path at least once a year.

Per-app logins, forward auth, native OIDC or a VPN gate?

These are not mutually exclusive. Most homelabs end up with a mix: native OIDC where apps support it, forward auth for the rest, and a VPN in front of anything administrative.

ApproachSecurityEffortFailure modesBest for
Per app loginsAs strong as the weakest app; MFA support uneven; no central revocationLow to start, high to maintainPassword reuse, forgotten accounts, no consistent rate limitingOne or two apps, one user
Forward auth at the proxyCentral MFA and policy; app trusts a header; parent domain cookieMedium: one portal plus a directive per routeDirect access bypasses it; missing directive fails open; API and mobile clients breakApps with no login of their own, admin UIs
Native OIDC per appSigned tokens validated by the app; per app cookies; group to role mappingMedium per app: register a client, configure the appWrong issuer or redirect URI; long app sessions after IdP logout; apps with weak OIDC supportApps with mature OIDC support: dashboards, git forges, document and photo apps
VPN gate in front of everythingNetwork level: nothing reachable without a VPN key; does not identify users per appMedium: VPN server plus a client on every deviceLost device keys; split tunnel mistakes; shared devices; guests need VPN accessAdmin consoles, apps never meant for the internet
VPN plus SSOLayered: network access and per user identity with MFAHighestMore pieces to maintain; IdP still a single point of failureAnything reachable from outside that holds sensitive data

Step by step: deploy an open source OIDC IdP in containers

This walkthrough uses Authelia as the IdP, Caddy as the reverse proxy, Grafana as an app with native OIDC, and oauth2-proxy in front of a second app as another OIDC client. It also protects a third app, which has no login, with forward auth. Every setting comes from the projects' own documentation; the same concepts apply to the other IdPs in the categories above, with different screens or files.

The example uses the domain home.example.com. Replace it with a domain you control. The IdP will live at auth.home.example.com, and that hostname becomes the WebAuthn Relying Party ID, so choose it with care.

Step 1: prerequisites

  • A host with Docker and the Compose plugin.
  • DNS records for auth, grafana, status and whoami under your domain, pointing at the host. A local resolver or split DNS works.
  • Certificates the browsers and containers trust. Caddy can issue them from a public CA with DNS-01, or from your own CA.
  • A hardware security key or a device with a platform authenticator, and ideally a second one as a spare.

Create the directory layout and lock down the secrets folder.

mkdir -p ~/sso/{authelia,secrets,caddy}
chmod 700 ~/sso/secrets
cd ~/sso

Step 2: generate secrets and the signing key

Authelia's documentation provides authelia crypto subcommands for random values and hashed client secrets. Generate each value into its own file. Do not paste secrets into the compose file.

# Random secrets for sessions, password reset tokens and storage encryption
for name in SESSION_SECRET JWT_SECRET STORAGE_ENCRYPTION_KEY OIDC_HMAC_SECRET; do
  docker run --rm docker.io/authelia/authelia:latest \
    authelia crypto rand --length 72 --charset rfc3986 \
    | awk '{print $NF}' > "secrets/$name"
done

# RSA key that signs ID tokens (RS256 is required by Authelia's jwks setting)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 \
  -out secrets/oidc_jwks_rsa.pem
chmod 600 secrets/*

Check that each file contains only the random value. The awk step strips the label that the command prints before the value; inspect one file with cat and adjust if your release prints a different format.

Generate a client secret for each confidential client. Authelia stores only a PBKDF2 digest of the secret; the app gets the plain value.

docker run --rm docker.io/authelia/authelia:latest \
  authelia crypto hash generate pbkdf2 --variant sha512 \
  --random --random.length 72 --random.charset rfc3986
# Prints a random password (give this to the app) and its digest (put it in Authelia)

Run that twice: once for Grafana, once for oauth2-proxy. Save each plain value in secrets/ and keep each digest for the next step.

Step 3: create the user store

For a household, Authelia's file backend is enough. Generate an Argon2 hash for each password interactively, so the password does not land in your shell history.

docker run --rm -it docker.io/authelia/authelia:latest \
  authelia crypto hash generate argon2

Then create authelia/users.yml:

users:
  alex:
    disabled: false
    displayname: 'Alex'
    password: '$argon2id$v=19$m=65536,t=3,p=4$REPLACE_WITH_YOUR_HASH'
    email: 'alex@home.example.com'
    groups:
      - 'family'
  alex-admin:
    disabled: false
    displayname: 'Alex (admin)'
    password: '$argon2id$v=19$m=65536,t=3,p=4$REPLACE_WITH_A_DIFFERENT_HASH'
    email: 'alex-admin@home.example.com'
    groups:
      - 'admins'

The everyday account and the admin account are separate on purpose. Use a long passphrase for each, which SP 800-63B-4 favours over composition rules.

Step 4: write the IdP configuration

Create authelia/configuration.yml. The jwks key uses Authelia's template filter to read the PEM file, which its provider documentation recommends; the filter is switched on in the compose file in step 6.

server:
  address: 'tcp://:9091/'

log:
  level: 'info'

theme: 'auto'

totp:
  disable: true            # WebAuthn only; re-enable as a fallback if you must

webauthn:
  disable: false
  display_name: 'Home SSO'
  attestation_conveyance_preference: 'indirect'
  timeout: '60 seconds'
  selection_criteria:
    discoverability: 'preferred'
    user_verification: 'required'

authentication_backend:
  file:
    path: '/config/users.yml'

access_control:
  default_policy: 'deny'
  rules:
    - domain: 'whoami.home.example.com'
      subject: 'group:family'
      policy: 'two_factor'
    - domain: 'whoami.home.example.com'
      subject: 'group:admins'
      policy: 'two_factor'

session:
  cookies:
    - domain: 'home.example.com'
      authelia_url: 'https://auth.home.example.com'
      inactivity: '30m'
      expiration: '8h'
      remember_me: '-1'     # see the note below

regulation:
  max_retries: 5
  find_time: '10m'
  ban_time: '30m'

storage:
  local:
    path: '/config/db.sqlite3'

notifier:
  filesystem:
    filename: '/config/notification.txt'

identity_providers:
  oidc:
    enforce_pkce: 'always'
    jwks:
      - key_id: 'home-rs256'
        algorithm: 'RS256'
        use: 'sig'
        key: {{ secret "/secrets/oidc_jwks_rsa.pem" | mindent 10 "|" | msquote }}
    lifespans:
      access_token: '30m'
      id_token: '30m'
      authorize_code: '1m'
      refresh_token: '90m'
    clients:
      - client_id: 'grafana'
        client_name: 'Grafana'
        client_secret: '$pbkdf2-sha512$310000$REPLACE_WITH_GRAFANA_DIGEST'
        public: false
        authorization_policy: 'two_factor'
        require_pkce: true
        pkce_challenge_method: 'S256'
        redirect_uris:
          - 'https://grafana.home.example.com/login/generic_oauth'
        scopes: ['openid', 'profile', 'email', 'groups']
        response_types: ['code']
        grant_types: ['authorization_code']
        access_token_signed_response_alg: 'none'
        userinfo_signed_response_alg: 'none'
        token_endpoint_auth_method: 'client_secret_basic'
      - client_id: 'status-proxy'
        client_name: 'Status page'
        client_secret: '$pbkdf2-sha512$310000$REPLACE_WITH_PROXY_DIGEST'
        public: false
        authorization_policy: 'two_factor'
        require_pkce: true
        pkce_challenge_method: 'S256'
        redirect_uris:
          - 'https://status.home.example.com/oauth2/callback'
        scopes: ['openid', 'profile', 'email', 'groups']
        response_types: ['code']
        grant_types: ['authorization_code']
        token_endpoint_auth_method: 'client_secret_basic'

Notes on the choices:

  • default_policy: 'deny' means any hostname without a rule is refused. Authelia's access control documentation recommends deny as the default.
  • enforce_pkce: 'always' raises the global PKCE policy from its default of public_clients_only to every client, and each client also pins S256.
  • user_verification: 'required' asks the authenticator for a PIN or biometric as well as touch.
  • Each redirect URI is a full, exact URL. There are no wildcards.
  • The example sets session inactivity and expiration within the SP 800-63B-4 AAL2 guidance. Authelia's remember_me default is one month; its session documentation says a value of -1 disables the feature for that cookie domain, which is what the example does. The same page notes that the passkey login flow currently needs remembered sessions, so leave passkey login off if you disable it.
  • The Grafana client follows Authelia's own Grafana integration guide, including the signed response algorithm settings that guide uses.
  • In production, replace the file notifier with SMTP so that WebAuthn registration emails reach you. During setup, the file notifier writes the registration link to notification.txt.

Step 5: write the reverse proxy configuration

Create caddy/Caddyfile. The portal and the two OIDC apps are plain reverse proxies. The whoami app has no login, so it gets forward_auth. The request_header lines strip any identity headers a client might send before the auth check runs.

auth.home.example.com {
	reverse_proxy authelia:9091
}

grafana.home.example.com {
	reverse_proxy grafana:3000
}

status.home.example.com {
	reverse_proxy status-proxy:4180
}

whoami.home.example.com {
	request_header -Remote-User
	request_header -Remote-Groups
	request_header -Remote-Email
	request_header -Remote-Name
	forward_auth authelia:9091 {
		uri /api/authz/forward-auth
		copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
	}
	reverse_proxy whoami:80
}

If you use your own CA, add a tls block or global ACME settings as described in your certificate setup. If Caddy itself sits behind another proxy, read Authelia's section on trusted proxies before you add a trusted_proxies setting, and list only the exact proxy addresses.

Step 6: write the compose file

Pin each image to the release tag you have reviewed, rather than latest. Only Caddy publishes ports; every app is reachable only on the internal network.

services:
  caddy:
    image: docker.io/library/caddy:${CADDY_TAG}
    ports: ['80:80', '443:443']
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    networks: [edge]

  authelia:
    image: docker.io/authelia/authelia:${AUTHELIA_TAG}
    environment:
      X_AUTHELIA_CONFIG_FILTERS: 'template'
      AUTHELIA_SESSION_SECRET_FILE: '/secrets/SESSION_SECRET'
      AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: '/secrets/JWT_SECRET'
      AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: '/secrets/STORAGE_ENCRYPTION_KEY'
      AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE: '/secrets/OIDC_HMAC_SECRET'
    volumes:
      - ./authelia:/config
      - ./secrets:/secrets:ro
    networks: [edge]

  grafana:
    image: docker.io/grafana/grafana:${GRAFANA_TAG}
    environment:
      GF_SERVER_ROOT_URL: 'https://grafana.home.example.com'
      GF_AUTH_GENERIC_OAUTH_ENABLED: 'true'
      GF_AUTH_GENERIC_OAUTH_NAME: 'Home SSO'
      GF_AUTH_GENERIC_OAUTH_CLIENT_ID: 'grafana'
      GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE: '/secrets/GRAFANA_CLIENT_SECRET'
      GF_AUTH_GENERIC_OAUTH_SCOPES: 'openid profile email groups'
      GF_AUTH_GENERIC_OAUTH_AUTH_URL: 'https://auth.home.example.com/api/oidc/authorization'
      GF_AUTH_GENERIC_OAUTH_TOKEN_URL: 'https://auth.home.example.com/api/oidc/token'
      GF_AUTH_GENERIC_OAUTH_API_URL: 'https://auth.home.example.com/api/oidc/userinfo'
      GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH: 'preferred_username'
      GF_AUTH_GENERIC_OAUTH_GROUPS_ATTRIBUTE_PATH: 'groups'
      GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH: 'name'
      GF_AUTH_GENERIC_OAUTH_USE_PKCE: 'true'
      GF_AUTH_GENERIC_OAUTH_AUTH_STYLE: 'InHeader'
      GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH: "contains(groups, 'admins') && 'Admin' || 'Viewer'"
    volumes:
      - ./secrets:/secrets:ro
    networks: [edge]

  status-proxy:
    image: quay.io/oauth2-proxy/oauth2-proxy:${OAUTH2_PROXY_TAG}
    environment:
      OAUTH2_PROXY_PROVIDER: 'oidc'
      OAUTH2_PROXY_OIDC_ISSUER_URL: 'https://auth.home.example.com'
      OAUTH2_PROXY_CLIENT_ID: 'status-proxy'
      OAUTH2_PROXY_CLIENT_SECRET_FILE: '/secrets/STATUS_CLIENT_SECRET'
      OAUTH2_PROXY_CODE_CHALLENGE_METHOD: 'S256'
      OAUTH2_PROXY_REDIRECT_URL: 'https://status.home.example.com/oauth2/callback'
      OAUTH2_PROXY_UPSTREAMS: 'http://status-app:80'
      OAUTH2_PROXY_HTTP_ADDRESS: '0.0.0.0:4180'
      OAUTH2_PROXY_EMAIL_DOMAINS: '*'
      OAUTH2_PROXY_ALLOWED_GROUPS: 'admins,family'
      OAUTH2_PROXY_SCOPE: 'openid profile email groups'
      OAUTH2_PROXY_COOKIE_SECRET_FILE: '/secrets/STATUS_COOKIE_SECRET'
      OAUTH2_PROXY_COOKIE_SECURE: 'true'
      OAUTH2_PROXY_COOKIE_EXPIRE: '8h'
    volumes:
      - ./secrets:/secrets:ro
    networks: [edge]

  status-app:
    image: docker.io/library/nginx:${NGINX_TAG}
    networks: [edge]

  whoami:
    image: docker.io/traefik/whoami:${WHOAMI_TAG}
    networks: [edge]

networks:
  edge: {}

volumes:
  caddy_data: {}

Three details from the projects' documentation are worth calling out. oauth2-proxy's cookie secret file must contain raw binary of exactly 16, 24 or 32 bytes, so generate it with dd if=/dev/urandom bs=32 count=1 of=secrets/STATUS_COOKIE_SECRET. oauth2-proxy's default cookie lifetime is 168 hours, which is a week, so set COOKIE_EXPIRE explicitly. The example does not set a cookie refresh interval, because refreshing needs a refresh token and this client is not granted one. And Grafana's generic OAuth settings support use_pkce; the role expression above is a JMESPath expression evaluated against the user's claims. Check the environment variable names for file based secrets against the documentation for the releases you pin, because they vary between projects.

Bring it up and watch the IdP log.

docker compose up -d
docker compose logs -f authelia

Step 7: verify the IdP before adding users to apps

Check the discovery document from a machine on your network.

curl -s https://auth.home.example.com/.well-known/openid-configuration \
  | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri,
         code_challenge_methods_supported, end_session_endpoint}'

Confirm three things. The issuer matches exactly, with no trailing slash surprises. code_challenge_methods_supported includes S256. And note whether end_session_endpoint is present, because that decides how logout works in step 9.

Step 8: enroll WebAuthn and test sign-in

  1. Open https://auth.home.example.com and sign in as alex with the password.
  2. Register a security key or passkey. With the file notifier, the one time registration link appears in authelia/notification.txt. Read it with cat, open the link, and complete the WebAuthn ceremony.
  3. Register a second authenticator as a spare. Repeat for the admin account, using a hardware security key.
  4. Open https://whoami.home.example.com. You should be redirected to the portal, asked for your key, and then see the whoami output, including the Remote-User header Caddy added.
  5. Open https://grafana.home.example.com and choose the SSO button. Because you already have an IdP session, the flow should complete without a password prompt. Check that alex lands as a Viewer and alex-admin as an Admin.
  6. Open https://status.home.example.com. oauth2-proxy runs its own code flow with PKCE and lets you through if your groups match.

Now prove the rules hold. In the browser's network panel, check that Grafana's authorization request carries code_challenge and code_challenge_method=S256. Change its redirect_uri to another path and load it: the IdP must refuse. From another LAN machine, try the apps' container ports directly: nothing should answer, because only Caddy publishes ports.

Step 9: test logout and token expiry

Logout is where homelab SSO setups most often surprise people. Test it deliberately.

  1. Sign in to all three apps.
  2. Log out at the IdP portal. Reload whoami: it should send you back to the login portal, because forward auth checks the IdP session on every request.
  3. Reload Grafana and the status page. If you are still signed in, they are using their own sessions, which is normal without back-channel logout. Note how long each lasts.
  4. If step 7 showed an end_session_endpoint, configure apps that support RP-initiated logout to use it, so that signing out of the app also ends the IdP session. If not, rely on short app sessions: Grafana's own session settings, and oauth2-proxy's COOKIE_EXPIRE.
  5. Test token expiry with a test client that uses an Authelia custom lifespan (attached with the client's lifespan setting) of five minutes. Confirm that after five minutes the app refreshes the token or sends you back to the IdP, rather than keeping you signed in indefinitely.

A quick way to inspect an ID token lifetime is to decode its payload, which is base64url JSON. Treat any token you copy as a secret and discard it after the test.

# Paste a test ID token into the variable, then decode the middle part
TOKEN='eyJ...test-token...'
printf '%s' "$TOKEN" | cut -d. -f2 | tr '_-' '/+' \
  | awk '{ l=length($0)%4; if (l==2) print $0"=="; else if (l==3) print $0"="; else print $0 }' \
  | base64 -d | jq '{iss, aud, exp, iat, sub}'

The difference between exp and iat should match the lifespan you configured.

Step 10: back up and document the break glass path

# Stop the IdP briefly so the SQLite file is consistent, then archive and encrypt
docker compose stop authelia
tar -czf - authelia secrets | gpg --symmetric --cipher-algo AES256 \
  -o "sso-backup-$(date +%F).tar.gz.gpg"
docker compose start authelia

Copy the encrypted archive off the host. Then write down, on paper, how to reach the hypervisor and router consoles without SSO, and where the spare security key and recovery passphrases live.

Common mistakes

Wildcard redirect URIs. A redirect URI of https://*.home.example.com/* or a bare * lets the IdP send authorization codes to any matching URL, including an open redirect on one of your own apps. RFC 9700 requires exact string matching. Register each callback URL in full.

Long lived sessions. A month long "remember me" cookie or a week long proxy cookie turns one stolen laptop into weeks of access to everything. Treat the AAL2 limits of 24 hours overall and 1 hour idle as a ceiling.

One admin account for everything. Using the same account to watch a film and to edit IdP clients means a stolen everyday session can rewrite your security policy. Keep a separate admin account with a hardware key and a short session.

No break glass path. An IdP that guards the hypervisor console it runs on can lock you out completely. Keep local, offline documented access to the infrastructure that runs the IdP.

Apps reachable around the proxy. Publishing an app's container port "just for testing" bypasses forward auth entirely, and anyone who can reach that port can supply their own identity header.

PKCE off or set to plain. plain exposes the verifier. Use S256 for every client.

TOTP as the only second factor. It is phishable and not phishing resistant under SP 800-63B-4. Make WebAuthn the default.

Changing the IdP hostname later. It means reconfiguring every client and re-registering every passkey.

Secrets in the compose file or git. Client secrets, the session secret and the signing key belong in protected files, never in committed YAML.

No restore test. A backup that has never been restored is a hope, not a backup.

FAQ

Do I really need single sign-on for a homelab?

Not for one or two apps used by one person. Once you run more than four or five web apps, share them with family, or expose any of them outside your network, one IdP with phishing resistant MFA is simpler and safer than many separate logins.

What is the difference between OAuth 2.0 and OpenID Connect?

OAuth 2.0 lets a client obtain an access token to call an API on a user's behalf. OpenID Connect adds an identity layer on top: an ID token that tells the app who signed in, plus standard scopes, claims and discovery. Apps that sign users in should use OpenID Connect.

Why use PKCE if my app already has a client secret?

A client secret proves which client is calling the token endpoint. PKCE proves that the client exchanging the code is the same one that started the login, which stops authorization code injection. RFC 9700 recommends PKCE for confidential clients and requires it for public ones.

Is forward auth as secure as native OIDC?

It can be safe, but it depends on the proxy and network being configured correctly, because the app trusts a header. Native OIDC lets the app validate a signed token itself and keeps a separate cookie per app. Use native OIDC where apps support it well, and forward auth for apps with no login.

Are passkeys phishing resistant?

Yes. WebAuthn binds each credential to the site's Relying Party ID and the browser includes the origin in what the authenticator signs, so a look-alike site cannot use the response. NIST SP 800-63B-4 treats such cryptographic authenticators as phishing resistant, and accepts synced passkeys at AAL2.

Should I use TOTP or a security key for my homelab?

Use a security key or passkey as the main second factor, and register two. Keep TOTP only as a fallback for devices that cannot do WebAuthn, or disable it once every user has two WebAuthn authenticators.

What happens if my identity provider goes down?

Every app that depends on it stops accepting new logins, and forward auth apps stop working entirely. Native OIDC apps may keep existing sessions until they expire. Keep a break glass path: local admin accounts on the hypervisor, router and storage that do not depend on SSO.

Should I expose my identity provider to the internet?

Only if you need remote access without a VPN, and then only the user facing login paths, never the admin console. Many homelabbers keep the IdP and its apps behind a VPN and get both network and identity layers.

How long should SSO sessions last?

NIST SP 800-63B-4 suggests that at AAL2 the overall session should be no more than 24 hours with an inactivity timeout of no more than 1 hour. For an IdP that opens everything you own, 8 to 12 hours overall and 15 to 30 minutes of inactivity is a reasonable homelab setting, with shorter limits for admin accounts.

Does logging out of the IdP log me out of every app?

Not always. Forward auth apps lose access immediately. Native OIDC apps keep their own sessions unless they implement RP-initiated or back-channel logout. Test logout explicitly and keep app sessions short.

Where Safeguard fits

Safeguard does not run your identity provider, enforce MFA, configure your reverse proxy or inspect your IdP settings. The fit is indirect and sits one layer down. A homelab SSO stack is a set of open source applications shipped as container images: the IdP, the proxy, the auth proxy and every app behind them, each carrying its own dependencies and base image. Safeguard can scan those images and the repositories you build them from for known vulnerable components, and show which of your projects carry a given component and version:

safeguard scan --image docker.io/authelia/authelia:<your-pinned-tag>
safeguard scan --dir .

Results are written as JSON under ./output. That helps when an advisory lands for a library inside your IdP image and you need to know whether your pinned tag is affected.

Supply chain hygiene still matters here because an identity provider is the most security critical software in a homelab, yet it is assembled from open source libraries for JWT handling, cryptography, HTTP parsing and databases, then shipped in a base image you did not build. Every update you pull, and every rebuild of the proxy or an app behind it, brings those components in again, so knowing which versions you run and replacing vulnerable ones quickly is part of keeping SSO safe.

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.

3. Gold Registry: start from components that are already fixed

registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

These demos show Safeguard working on software projects, not on identity providers or login flows. They apply to the repositories, compose files and container images that make up your SSO stack.

The first demo is a real Safeguard CLI session. After install, safeguard opens the interactive terminal, you ask it to fix the lodash CVE in a repository, and it reads the finding and advisory, asks before each command, edits package.json to lodash 4.17.21, runs npm install and npm test with your approval, prints a summary and pushes nothing. The same ask, approve and test loop fits a small internal app you now put behind your IdP.

Click through it at your own pace, or open it full screen.

A homelab IdP usually lives on a network nobody outside can reach. The local runner demo shows how to set up the runner on your own machine, approve it with its device code, and then scan from the terminal. A runner on the same host as your compose stack can scan the pinned IdP, proxy and app images without opening anything to the internet.

Click through it at your own pace, or open it full screen.

If you work with Claude, the last demo shows how to connect Claude to the Safeguard MCP server with OAuth and choose which tools Claude can use. It is the same authorization pattern this post describes, seen from the client side: an OAuth sign-in, then a deliberate choice of what the client may do.

Click through it at your own pace, or open it full screen.

To try it on your own repositories and images, download Safeguard and follow the CLI documentation.

Quick check

Your dashboard is registered with the redirect URI https://grafana.home.example.com/login/generic_oauth. A login request arrives with redirect_uri set to https://grafana.home.example.com/login/generic_oauth/../../redirect. What should the IdP do?

Refuse it. RFC 9700 requires exact string matching of redirect URIs (with one exception for loopback ports in native apps), and OpenID Connect Core requires the value to exactly match a registered redirect URI using simple string comparison. Any difference, including path tricks, means no redirect and no code.

A family member signs in to the IdP with a password and a six digit TOTP code. Is that phishing resistant under NIST SP 800-63B-4?

No. SP 800-63B-4 states that authenticators requiring manual entry of an output, such as OTP codes, are not phishing resistant, because an impostor site can relay the code. A WebAuthn passkey or security key, which binds its response to the site's origin, is.

Sources

Last reviewed: 24 October 2026. Configuration keys and defaults change between releases; check the documentation for the versions you pin, and test sign-in, logout and your break glass path after every upgrade.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.