Authunnel is an authenticated tunnel for reaching private TCP services, including SSH, through an OAuth2-protected TLS WebSocket conduit.
The target workflow is:
sshlaunches the Authunnel client asProxyCommand.- The client reuses a cached token, refreshes it, or completes Authorization Code + PKCE in a browser.
- The Authunnel server, acting as an OAuth2 resource server, uses OIDC discovery to locate the issuer's JWKS endpoint and validates the JWT access token locally.
- The server hosts a SOCKS5 backend and opens the requested
%h:%pdestination. - SSH stdio is bridged over that authenticated path.
Authunnel authenticates the network path to a private service: it decides, using OIDC, who may open a tunnel to a destination. It does not provide OIDC-authenticated SSH login. Once the tunnel is open, SSH performs its own host and user authentication (keys, certificates, passwords) exactly as it would over any other transport. Authunnel never sees or brokers your SSH credentials, and a valid OIDC token does not by itself grant a login to any host.
In other words, Authunnel provides bastion-like functionality — a single authenticated, audited entry point to an internal network — rather than federating SSH login to your identity provider.
If you instead want OIDC identity to issue the SSH credential itself, use a purpose-built tool built on open source and open standards, optionally alongside Authunnel:
- opkssh / OpenPubkey — embeds an OIDC ID token in the SSH public key so OpenSSH can authenticate the user against their IdP (github.com/openpubkey/opkssh).
- Smallstep
step-cawith its OIDC provisioner — an open source SSH certificate authority that issues short-lived SSH user certificates after an OIDC browser login (smallstep.com/docs/step-ca).
These compose naturally with Authunnel: OIDC governs the tunnel (network admission and audit) while an OIDC-backed SSH CA governs the login on the host at the far end.
- docs/DEPLOYMENT.md — running the server: TLS modes, reverse-proxy configuration, the full server flag reference, egress policy, OIDC client registration, and the deployment hardening checklist.
- docs/DEVELOPMENT.md — building and testing from source: codebase layout, auth-flow invariants, the test suite, and the local Keycloak environment.
server/server.go- HTTPS server on a configurable listen address (default
:8443for TLS-files,:443for ACME,:8080for plaintext-behind-reverse-proxy) - Conservative HTTP server timeouts to reduce slow-client resource exhaustion risk
- Structured JSON logs with three correlation IDs:
request_id— generated per HTTP request; scoped to a single request/response cycletrace_id— extracted from an incomingTraceparentheader (W3C Trace Context) when present, otherwise generated; allows correlation with upstream infrastructure such as a load balancer or reverse proxytunnel_id— generated when a WebSocket upgrade succeeds; scoped to the lifetime of the SOCKS tunnel and inherited by all subsequent tunnel events (open, SOCKS CONNECT, close)
- Tunnel logs include the authenticated user identity, with per-destination SOCKS CONNECT logs at debug level; all three correlation IDs are carried through so HTTP admission, tunnel lifecycle, and per-destination events can be joined
- OAuth2 resource-server JWT validation: OIDC discovery used only to bootstrap the JWKS endpoint, all token verification done locally
- Optional admission controls: global and per-user concurrent-tunnel caps, per-user tunnel-open rate limit, and a bounded dial timeout for outbound SOCKS CONNECT
- WebSocket tunnel endpoint (
/protected/tunnel) connected to an in-process SOCKS5 server
- HTTPS server on a configurable listen address (default
client/client.go- ProxyCommand mode: stdio bridge for direct SSH integration
- Unix socket mode: local SOCKS5 endpoint for generic client tooling
- Managed OIDC mode: public-client PKCE login with token cache + refresh
- Control-message listener for server-initiated longevity warnings; automatic token refresh when the server signals imminent token expiry
- Reads OIDC issuer, audience, listen address, TLS mode, and connection longevity configuration from flags or environment.
- Performs OIDC discovery once at startup, solely to locate the issuer's JWKS endpoint.
- Accepts
GET /protected/tunnel, verifies the bearer token's signature, issuer, expiration, audience, subject presence,iatsanity, andnbf(the token must be usable now at admission), then checks the WebSocket upgrade headers. UnauthenticatedGETrequests under/protected/receive401; other HTTP methods receive405from the router. - Applies admission controls (concurrent-tunnel caps and per-user rate limits) when configured, rejecting over-limit requests with
429/503and aRetry-Afterheader. - Upgrades the connection to WebSocket.
- Hands each upgraded connection to the SOCKS5 server implementation.
- If connection longevity is configured, manages tunnel lifetime: warns clients before expiry and disconnects when limits are reached. When token-expiry enforcement is active, the server accepts refreshed tokens from the client to extend the tunnel.
- Emits structured JSON logs for request lifecycle, auth failures, tunnel open/close events, token refresh outcomes, and debug-level SOCKS CONNECT destinations.
- Either:
- uses a bearer token supplied via the
ACCESS_TOKENenvironment variable, or - runs managed OIDC mode when
--oidc-issuerand--oidc-client-idare configured.
- uses a bearer token supplied via the
- In managed mode the client:
- reuses a cached token when it remains valid for more than 60 seconds,
- otherwise refreshes it when a refresh token is available,
- otherwise launches a browser to the IdP and listens on
127.0.0.1for the callback.
- The client opens an authenticated WebSocket connection to the Authunnel server.
- A background control-message listener handles server-initiated longevity messages. When the server warns that the access token is about to expire, the client automatically obtains a fresh token (via its existing refresh logic) and sends it to the server to extend the tunnel.
- In ProxyCommand mode it performs a SOCKS5 CONNECT for
%h:%pand bridgesstdin/stdout. - In unix-socket mode it exposes a local SOCKS5 endpoint and opens a dedicated tunnel per local connection.
Authunnel is deliberately simple in both functionality and implementation — a small, focused codebase that is intended to be easy to read and audit in full. Complexity is kept low by design; if a feature would make the security model harder to reason about, that is a reason not to add it.
The following properties are enforced by default with no silent bypass. Where a development override exists it is noted explicitly:
- Bearer token validation at the WebSocket layer before any SOCKS5 connection can be attempted: signature, issuer, audience (
aud), expiry (exp), non-empty subject (sub), not-before (nbfmust be usable at admission time, with a 30-second clock-skew allowance), and sane issued-at (iatmust not be meaningfully in the future). The bearer token is length-capped at 8 KiB and theAuthorizationheader at 8 KiB + 64 bytes before the verifier runs, so anonymous callers cannot push oversized payloads onto the JWT parser. Thehttp.Serverrequest-header memory cap is also lowered from Go's 1 MiB default to 16 KiB as a defence-in-depth boundary against oversized non-bearer headers. - Bounded OIDC discovery and JWKS fetches: both the server-side validator and the managed client share an HTTP transport with conservative dial, TLS-handshake, response-header, and overall timeouts. A stalled or unreachable issuer fails closed instead of holding startup or in-flight token validation open. Server startup wraps OIDC discovery in a 30-second context, so a misconfigured issuer surfaces as a fast
create token validatorerror. - Subject pinning during token refresh: the server rejects any refreshed token whose
subdiffers from the original tunnel's subject. - Refresh deadline enforcement: a refreshed token whose
nbffalls after the current enforced connection deadline (exp + --expiry-grace) is rejected. A refresh handover cannot silently extend the policy beyond what the operator has opted into. The comparison is strict — no additional clock-skew allowance applies beyond--expiry-grace. - Secure transport by default: the OIDC issuer URL must be
https://; the client's tunnel endpoint URL must behttps://orwss://. Plaintext variants require explicit override flags (see the development-only overrides in the server flag reference). - Explicit egress posture at startup: the server refuses to start without either
--allowrules or--allow-open-egress. This prevents a misconfigured deployment from silently becoming an open TCP pivot.
The following are disabled or unlimited by default and must be explicitly configured for a hardened deployment. docs/DEPLOYMENT.md covers each in detail and ends with a hardening checklist to verify before going to production:
- Egress allowlist (
--allow): limits the destinations authenticated clients may reach. Recommended for production; restricts the blast radius if a credential is compromised. - Egress open mode (
--allow-open-egress): explicit opt-in to allow any destination reachable by the server process. Logged at warn level on startup. Mutually exclusive with--allow. - Resolved-IP deny-list (
--ip-block,--no-ip-block): on by default with a built-in protected set — loopback, IPv4/IPv6 link-local (incl. cloud IMDS169.254.169.254), unspecified, and multicast. Applied independently of the egress posture: the deny-list runs after the allow check in both restrictive and open modes, so a hostname rule that resolves to a protected address is rejected regardless. RFC1918, CGNAT, and IPv6 ULA are not in the default set.--ip-blockreplaces the default with an operator-supplied list (CIDR, bare IP, or bracketed IPv6);--no-ip-blockdisables the guard entirely. - Connection longevity (
--max-connection-duration,--expiry-grace,--no-connection-token-expiry): by default tunnel lifetime is tied to the access token'sexp. These flags let operators tune for specific IdP behaviors or impose hard ceilings. Some IdPs (e.g. Auth0) cache access tokens;--expiry-graceextends the enforcement deadline beyondexpto give the client time to obtain a genuinely new token. - Admission limits (
--max-concurrent-tunnels,--max-tunnels-per-user,--tunnel-open-rate,--dial-timeout): zero or default by default. Configure for production to bound resource use and prevent a single credential from monopolising tunnel capacity or tying up goroutines on blackholed destinations. - Pre-auth IP rate limit (
--preauth-rate,--preauth-burst): off by default, matching the explicit-posture style of the egress flags. When enabled, runs before bearer-token parsing on every authenticated route (/protected,/protected/, any/protected/*, and/protected/tunnel) so a flood of anonymous or junk-JWT requests is rejected with429before any validator or JWKS work happens. Recommended for direct internet exposure; deployments behind a load balancer that already rate-limits anonymous traffic can leave it off. Buckets key on the TCP peer by default;--preauth-trust-forwarded-foropts into trustingX-Forwarded-Forbehind a known proxy (see docs/DEPLOYMENT.md).
- Live token revocation: revoking a token at the IdP does not terminate an already-established tunnel. Authunnel enforces token expiry but does not perform per-request introspection checks.
- Tunnel chain observability: Authunnel can only log and control connections it directly brokers. A client could SOCKS CONNECT to a second tunnel or proxy, creating a chain Authunnel cannot observe.
- OIDC-authenticated SSH login: Authunnel authenticates the tunnel, not the SSH session. It does not federate host login to your IdP — see Scope: tunnel authentication, not SSH login for the distinction and for open-source tools that do.
- Session architecture redesign: the current WebSocket-to-SOCKS model is intentionally simple and is not expected to change.
- Released
authunnel-serverandauthunnel-clientbinaries for your platform. Go is only needed if you build from source or run thego runexamples below. - An OIDC provider that issues JWT access tokens carrying both a server audience (emitted as
aud) and a non-emptysub— Authunnel pins each tunnel's refresh identity tosub, so tokens without one are rejected at admission. Most IdPs emitsubby default; on Keycloak 26+ the client's default scopes must cover it (the built-inbasicscope, or an equivalent custom scope with anoidc-sub-mapper— seetestenv/keycloak/authunnel-realm.jsonfor a working example) - A TLS certificate trusted by the client runtime (for TLS-files mode; not required for ACME or plaintext-behind-reverse-proxy modes)
The server runs on Linux and macOS. The client runs on Linux, macOS, and Windows (10 1803 or later).
The examples below use go run from a source checkout. When using released
binaries, invoke authunnel-server or authunnel-client with the same flags
and environment variables.
Server setup is covered in full in docs/DEPLOYMENT.md — TLS modes (certificate files, ACME / Let's Encrypt, plaintext behind a reverse proxy), the complete flag reference, and egress policy. A minimal example using TLS certificate files:
export OIDC_ISSUER='https://<issuer>'
export TOKEN_AUDIENCE='authunnel-server'
export TLS_CERT_FILE='/etc/authunnel/tls/server.crt'
export TLS_KEY_FILE='/etc/authunnel/tls/server.key'
cd server && CGO_ENABLED=0 go run . --allow '*.internal:22'Egress posture is required at startup. Either pass one or more --allow rules (recommended) or pass --allow-open-egress to explicitly opt into open mode. Running without either is rejected — see the "Security Posture" section above.
This is the intended ssh workflow.
Example SSH config entry:
Host internal-host
HostName internal-host
User myuser
ProxyCommand /path/to/authunnel-client \
--tunnel-url https://localhost:8443/protected/tunnel \
--oidc-issuer https://<issuer> \
--oidc-client-id authunnel-cli \
--proxycommand %h %pOn Windows with OpenSSH, use the full path with backslashes and quote it if it contains spaces:
Host internal-host
HostName internal-host
User myuser
ProxyCommand "C:\path\to\authunnel-client.exe" --tunnel-url https://... --oidc-issuer https://<issuer> --oidc-client-id authunnel-cli --proxycommand %h %pUseful client flags:
--oidc-issuer--oidc-client-id--oidc-audienceto request a specific API/resource audience during managed login--oidc-redirect-portto use a fixed loopback callback port instead of a random one--oidc-scopeswith defaultopenid offline_access--oidc-cachewith default${XDG_CONFIG_HOME:-~/.config}/authunnel/tokens.json(macOS/Linux) or%AppData%\authunnel\tokens.json(Windows)--oidc-no-browserto print the URL without attempting automatic browser launch--tunnel-url— tunnel endpoint URL. Secure schemeshttps://andwss://are accepted by default; plaintexthttp://andws://require--insecure-tunnel-url. Required. May also be supplied via theAUTHUNNEL_TUNNEL_URLenvironment variable (the flag takes precedence)--unix-socket--proxycommand--insecure-oidc-issuer— allow a non-HTTPS OIDC issuer URL (development only; do not use in production)--insecure-tunnel-url— allow a non-HTTPS tunnel endpoint URL (development only; do not use in production)
On first use the client prints the authorization URL to stderr and tries to open the system browser. Subsequent runs reuse the cache or refresh token when possible.
The IdP-side client registration required for managed mode (public client, PKCE, loopback redirects, refresh tokens) is described in docs/DEPLOYMENT.md.
A pre-obtained bearer token can be supplied via the ACCESS_TOKEN
environment variable. This is mutually exclusive with all managed OIDC
flags. There is no command-line equivalent: bearer tokens passed as
arguments would be visible via process listings and shell history.
The examples below source the token from a secrets manager so the literal
value never appears in shell history or argv. Substitute whichever helper
you use (pass, vault kv get, op read, security find-generic-password -w, gpg --decrypt, etc.); the goal is that the token comes from outside
the typed command line.
# The ACCESS_TOKEN= prefix scopes the value to this single client
# invocation; it is not exported to the shell. Avoid
# `export ACCESS_TOKEN=<literal>`, which writes the token to shell history.
cd client
ACCESS_TOKEN="$(pass show authunnel/access-token)" \
CGO_ENABLED=0 SSL_CERT_FILE=../cert.pem go run . \
--tunnel-url https://localhost:8443/protected/tunnel \
--unix-socket /tmp/authunnel/proxy.sockProxyCommand example, same pattern:
ACCESS_TOKEN="$(pass show authunnel/access-token)" /path/to/authunnel-client \
--tunnel-url https://localhost:8443/protected/tunnel \
--proxycommand internal-host 22If you already export ACCESS_TOKEN from a wrapper script or a
shell-startup integration with your secrets manager, you can omit the
inline substitution and just invoke the client directly.
cd client
CGO_ENABLED=0 SSL_CERT_FILE=../cert.pem go run . \
--tunnel-url https://<host>:8443/protected/tunnel \
--oidc-issuer https://<issuer> \
--oidc-client-id authunnel-cli \
--unix-socket /tmp/authunnel/proxy.sockUse with socat in an SSH ProxyCommand:
Host internal-host-via-socat
HostName internal-host
User myuser
ProxyCommand socat - SOCKS5:/tmp/authunnel/proxy.sock:%h:%pIf the unix-socket parent directory does not already exist, the client creates
it with 0700 permissions. It also tightens the socket itself to 0600 so
other local users cannot connect by default on shared hosts.
On shared POSIX hosts the client fails closed if the socket's parent directory
is group- or world-writable, or if it is owned by another local user. It also
walks every ancestor up to the filesystem root: any ancestor directory a peer
can rename(2) past would let them swap the private subtree between
validation and bind, so ancestors that are writable by others without the
sticky bit, or owned by an unprivileged user other than the operator, are
rejected too. Sticky directories (the classic case is /tmp, mode 1777)
are accepted as ancestors because sticky-bit semantics restrict renames to
the entry's owner — but the leaf must still be a private subdirectory (for
example /tmp/authunnel/, mode 0700), so point --unix-socket at a file
inside it rather than directly at /tmp/proxy.sock. A bare filename like
--unix-socket proxy.sock is validated against the current working
directory under the same rules, so starting the client from a shared cwd
(such as /tmp itself) is refused. The same checks apply to the OIDC token
cache directory (--oidc-cache) and its advisory-lock companion file, so a
directory that is safe for the socket is also safe for cached tokens.
Managed OIDC mode writes the cached access token and refresh token to
--oidc-cache as plaintext JSON. Confidentiality on disk is enforced by
POSIX filesystem permissions alone: the cache file is created 0600 via
atomic rename, inside a 0700 directory whose ancestors have been
validated against peer rename(2) as described above.
The client also re-validates an existing cache file before reading it, so
a tokens.json left over from another tool with 0o644 (or any
group/world bit), with a foreign owner, or replaced by a symlink is
rejected with a validate OIDC token cache: startup error rather than
silently honoured. The fix is one of chmod 600 ~/.config/authunnel/tokens.json (POSIX) or deleting the file and
re-authenticating; the validator deliberately does not auto-chmod, so
the audit signal is preserved.
This design matches the pattern used by most OIDC CLIs, but operators should be explicit about what it does and does not defend against:
- Defended: read access by other unprivileged users on the same host, including concurrent attackers who can observe the config directory but not write into it.
- Not defended: the machine's root user, offline forensic access to an unencrypted disk or disk image, backups of the user's config directory, or any process running as the same uid (which by construction already has the same tokens available through the authunnel client itself).
If your threat model requires stronger at-rest protection, either run
authunnel on a system with full-disk encryption (so offline disk access is
excluded), or supply the access token directly via ACCESS_TOKEN from a
secrets manager so no refresh token is ever persisted by authunnel.
During listener creation the client restricts its process umask to 0o077,
so the socket inode is created owner-only in the first place; the follow-up
chmod to 0600 is kept as a safety net for filesystems that ignore umask
on AF_UNIX bind. Stale-socket cleanup after a previous crash refuses to
remove anything other than a unix-domain socket owned by the current user,
so a regular file accidentally placed at the socket path will surface as an
error rather than being silently unlinked.
Unix socket mode works on Windows 10 1803 and later. Windows uses NTFS ACLs
rather than POSIX mode bits, so the parent-directory safety check there only
verifies that the target path exists as a directory; detailed ACL inspection
is out of scope and operators should rely on the default %AppData%
location, which is already user-scoped.
Authunnel follows Semantic Versioning. A new major version may introduce breaking changes to configuration flags, environment variables, or the wire protocol. Check the release notes before upgrading across a major version boundary.
Release assets are accompanied by SHA-256 checksums and GitHub artifact attestations. After downloading an asset, verify its provenance with:
gh attestation verify authunnel-client-linux-amd64 -R dkj/authunnelSee LICENSE.