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.
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 Development overrides in the flag reference below). - 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:
- 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.
- 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.
- Session architecture redesign: the current WebSocket-to-SOCKS model is intentionally simple and is not expected to change.
Before going to production, verify:
- OIDC issuer is
https://—--insecure-oidc-issueris not set. - Tunnel endpoint is
https://orwss://—--insecure-tunnel-urlis not set on the client. - Token-expiry enforcement is active —
--no-connection-token-expiryis not set. By default, tunnels close when the access token expires and clients must refresh. Disabling this removes token expiry as a tunnel lifetime control; tunnels will still close at--max-connection-durationif set, but without that limit they persist until the client disconnects. - At least one
--allowrule is configured.--allow-open-egressshould only appear in deployments where arbitrary authenticated egress from the server host is explicitly acceptable. - The default
--ip-blockset is in effect (loopback, link-local incl. IMDS, unspecified, multicast), or any deviation via--ip-block/--no-ip-blockis intentional and documented for the deployment. - A hard connection ceiling is set (
--max-connection-duration) appropriate for your session-length policy. - Admission limits are sized for expected load:
--max-concurrent-tunnels,--max-tunnels-per-user, and--tunnel-open-rateare set. -
--dial-timeoutis set (default10s). Setting it to0allows authenticated users to hold goroutines open on blackholed destinations indefinitely. - The unix-socket path (if used) lives inside a private directory such as
/tmp/authunnel/(0700), not directly under a world-writable parent like/tmp. - The authunnel server (if using
--plaintext-behind-reverse-proxy) is not directly reachable over untrusted networks — only the TLS-terminating reverse proxy should be. The proxy must also overwrite (not append to) client-suppliedX-Forwarded-Proto,X-Forwarded-Host, andX-Forwarded-Forheaders before forwarding; the last one is consulted by--preauth-ratefor per-IP bucketing and an appended client value lets attackers spoof buckets.
- 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.
Choose one TLS mode. All modes also accept --oidc-issuer, --token-audience, --listen-addr, --log-level, and --allow.
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.
TLS certificate files (default :8443):
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'The server validates the TLS key file at startup on POSIX. The resolved target must:
- be a regular file with no group or world permission bits (
mode & 0o077 == 0, e.g.0600or0400), - be owned by the current user or by root — any other unprivileged owner could read the key, so accepting that ownership would defeat the "unreadable by others" contract,
- live under a parent chain that is itself safe against
rename(2).
Symlinks are followed so canonical certbot paths such as
/etc/letsencrypt/live/<domain>/privkey.pem work out of the box; both
the un-resolved and resolved parent chains are checked for ancestor
safety. As a final step the server opens the key once to confirm it can
actually read it, so an ACL or group-membership mismatch surfaces at
startup rather than mid-handshake. Any failure logs tls_key_file_unsafe
and exits. The cert file is public material and is not validated.
ACME / Let's Encrypt (default :443; server must be reachable on port 443):
export OIDC_ISSUER='https://<issuer>'
export TOKEN_AUDIENCE='authunnel-server'
export ACME_DOMAINS='authunnel.example.com'
export ACME_CACHE_DIR='/var/cache/authunnel/acme'
cd server && CGO_ENABLED=0 go run . --allow '*.internal:22'Certificates are obtained and renewed automatically using the TLS-ALPN-01 challenge. The cache directory must be writable by the server process and should persist across restarts to avoid hitting Let's Encrypt rate limits. autocert writes Let's Encrypt private keys into this directory, so on POSIX the server applies the same ancestor + leaf checks used for the OIDC cache: the directory is created 0o700 if missing, and an existing one is rejected if it is group/world writable, owned by another unprivileged user, or sits beneath a permissive ancestor.
Plaintext HTTP (default :8080; for use behind a TLS-terminating reverse proxy):
export OIDC_ISSUER='https://<issuer>'
export TOKEN_AUDIENCE='authunnel-server'
cd server && CGO_ENABLED=0 go run . --plaintext-behind-reverse-proxy --allow '*.internal:22'The server trusts X-Forwarded-Proto and X-Forwarded-Host for WebSocket origin checks. When --preauth-rate is set, the server additionally trusts the leftmost X-Forwarded-For entry as the client-IP key for the pre-auth limiter, falling back to the TCP peer address when XFF is absent. Most proxies forward these headers automatically; nginx requires explicit configuration:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;Security note: The reverse proxy must overwrite (not append to) any X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-For headers supplied by clients before forwarding requests to the backend. If client-supplied headers are forwarded unchanged or appended to, a malicious client can set them to arbitrary values and influence the WebSocket origin check (X-Forwarded-Proto/X-Forwarded-Host) or spoof per-IP buckets in the pre-auth limiter (X-Forwarded-For, since Authunnel keys on the leftmost entry — every spoofed IP gets its own bucket, so the limiter no longer bounds anonymous cost). Add the following to your nginx configuration to ensure these headers carry only proxy-issued values:
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $remote_addr;Note $remote_addr (overwrite), not $proxy_add_x_forwarded_for (append) — the latter preserves any client-supplied prefix and defeats the bucket-keying.
Default behaviour for X-Forwarded-For is append, not overwrite, on every common reverse proxy — including AWS ALB, HAProxy (option forwardfor), Caddy 2's reverse_proxy, and Traefik. Concretely, that means the leftmost entry is client-controlled by default. If you cannot make your proxy overwrite XFF, leave --preauth-rate at 0 (off); the per-IP limiter is otherwise spoofable. Per-proxy notes:
- nginx: use
proxy_set_header X-Forwarded-For $remote_addr(overwrite), as shown above. Avoid$proxy_add_x_forwarded_for. - HAProxy:
option forwardforappends. To overwrite, drop that option and usehttp-request set-header X-Forwarded-For %[src]instead. - Caddy: in the
reverse_proxyblock useheader_up X-Forwarded-For {remote_host}to overwrite (Caddy otherwise appends). - AWS ALB: ALB always appends to client-supplied
X-Forwarded-For. There is no overwrite mode. If the listener is internet-facing, treat--preauth-rateas unsafe and either keep it off or terminate at a proxy you control before forwarding to ALB-fronted Authunnel. - Traefik: appends by default; an explicit middleware (e.g.
headers.customRequestHeaders) is required to overwrite.
For X-Forwarded-Proto and X-Forwarded-Host (used only by the WebSocket origin check), Caddy, AWS ALB, Traefik, and HAProxy generally set sane values, but you should still explicitly configure them so the value is proxy-issued rather than client-passthrough.
Useful server flags and environment variables:
--oidc-issuerorOIDC_ISSUER--token-audienceorTOKEN_AUDIENCE--listen-addrorLISTEN_ADDR(default varies by TLS mode; see above)--log-levelorLOG_LEVELwith defaultinfo--tls-certorTLS_CERT_FILE— path to TLS certificate PEM--tls-keyorTLS_KEY_FILE— path to TLS private key PEM--acme-domainorACME_DOMAINS(comma-separated) — domain(s) for automatic ACME certificate; repeatable--acme-cache-dirorACME_CACHE_DIRwith default/var/cache/authunnel/acme--plaintext-behind-reverse-proxyorPLAINTEXT_BEHIND_REVERSE_PROXY=true— serve plain HTTP, trusting a TLS-terminating reverse proxy for transport security;X-Forwarded-ProtoandX-Forwarded-Hostare used for WebSocket origin checks--alloworALLOW_RULES(comma-separated in env) — restrict outbound connections to matching rules; repeatable. At least one rule is required unless--allow-open-egressis set--allow-open-egressorALLOW_OPEN_EGRESS=true— explicit opt-in for running with no allowlist; mutually exclusive with--allow. Use only when arbitrary authenticated egress from the server host is acceptable for the deployment--ip-blockorIP_BLOCK(comma-separated in env) — resolved-IP deny-list applied after--allow; repeatable. Accepts CIDR (127.0.0.0/8), bare IP (127.0.0.1), or bracketed IPv6 ([::1],[fe80::/10]). When unset and--no-ip-blockis not set, defaults to the built-in protected set (loopback, IPv4/IPv6 link-local incl. IMDS169.254.169.254, unspecified, multicast). Applies in both restrictive and open-egress modes; deny wins over--allow--no-ip-blockorNO_IP_BLOCK=true— disable the resolved-IP deny-list entirely; mutually exclusive with--ip-block. Use only when the deployment legitimately needs to reach default-protected addresses (e.g. tunnelling to a localhost service) and a tighter--ip-blocklist is not sufficient--insecure-oidc-issuerorINSECURE_OIDC_ISSUER=true— allow a non-HTTPS OIDC issuer URL (development only; do not use in production)--max-connection-durationorMAX_CONNECTION_DURATION— hard maximum tunnel lifetime (e.g.4h,30m); default0(unlimited)--no-connection-token-expiryorNO_CONNECTION_TOKEN_EXPIRY=true— do not tie tunnel lifetime to access token expiry; by default expiry IS enforced and clients can refresh tokens to extend. Setting this and leaving--max-connection-durationat0removes every enforced lifetime cap; the server logs aconnection_lifetime_unboundedwarning at startup so the posture is visible in logs--expiry-warningorEXPIRY_WARNING— warning period before either longevity limit; default3m--expiry-graceorEXPIRY_GRACE— extend the connection deadline beyond the access token'sexpclaim to accommodate providers (e.g. Auth0) that cache access tokens; default0(no grace)--max-concurrent-tunnelsorMAX_CONCURRENT_TUNNELS— server-wide cap on simultaneous tunnels; default0(unlimited). Over-cap requests receive503 Service UnavailablewithRetry-After.--max-tunnels-per-userorMAX_TUNNELS_PER_USER— per-subject cap on simultaneous tunnels, keyed on the OIDCsubclaim; default0(unlimited). Over-cap requests receive429 Too Many RequestswithRetry-After.--tunnel-open-rateorTUNNEL_OPEN_RATE— per-user tunnel-open rate (tunnels/sec); default0(disabled). Exceeding the rate yields429withRetry-Afterderived from the token-bucket delay.--tunnel-open-burstorTUNNEL_OPEN_BURST— burst size for the per-user rate limiter; defaults toceil(rate)when rate is set. Setting burst without rate is a startup error.--dial-timeoutorDIAL_TIMEOUT— per-outbound-dial timeout applied to SOCKS CONNECT destinations; default10s. Bounds failure time against blackholed targets.--preauth-rateorPREAUTH_RATE— per-source-IP rate limit applied before token parsing on every authenticated route (/protected,/protected/, any/protected/*, and/protected/tunnel); requests/sec; default0(disabled), max10000. Behind a load balancer that already rate-limits anonymous traffic this can stay off; enable it for direct internet exposure so junk JWTs and oversized headers are rejected with429before reaching the validator. When--plaintext-behind-reverse-proxyis set, the limiter keys on the leftmostX-Forwarded-Forentry, falling back to the TCP peer address; otherwise it always keys on the TCP peer.--preauth-burstorPREAUTH_BURST— burst size for--preauth-rate; defaults toceil(rate)when the rate is set. Setting burst without rate is a startup error.
Admission rejections are emitted as structured warn log records with event=tunnel_admission_denied and a reason field (global, per_user, or rate), so operators can distinguish abuse from undersized limits without adding a metrics stack. Pre-auth rejections are logged separately with event=preauth_rate_limited so the two layers can be told apart in queries. Per-user policy is keyed on the OIDC sub claim; tokens without a stable subject are rejected earlier by the JWT validator before admission runs.
Rule formats: host-glob:port, host-glob:lo-hi, CIDR:port, CIDR:lo-hi, [IPv6]:port, [IPv6]:lo-hi
IPv6 addresses must use bracketed notation ([addr]:port). Unbracketed IPv6 is rejected at startup because the last-colon port split is otherwise ambiguous.
A resolved-IP deny-list runs after the allow check, independently of the egress posture. By default it covers loopback (127.0.0.0/8, ::1), IPv4 link-local (169.254.0.0/16, including IMDS 169.254.169.254), IPv6 link-local (fe80::/10), unspecified (0.0.0.0/8, ::), and multicast (224.0.0.0/4, ff00::/8). A request that the allow-list permits but whose resolved address falls in the deny-list is rejected with event=socks_connect_denied_ip_blocked and a reason field (loopback, link_local_ipv4, link_local_ipv6, unspecified, or multicast). RFC1918, CGNAT, and IPv6 ULA ranges are not in the default set.
To replace the default deny-list, pass one or more --ip-block rules (or set IP_BLOCK):
# Block only IMDS; loopback becomes reachable subject to --allow
authunnel-server --allow '127.0.0.1:5432' --ip-block '169.254.0.0/16'To disable the guard entirely, pass --no-ip-block. This is the only way to reach default-protected addresses when a tighter --ip-block list is not sufficient (for example, when running with --allow-open-egress and a deliberate need to reach loopback):
authunnel-server --allow '127.0.0.1:5432' --no-ip-block
authunnel-server --allow-open-egress --no-ip-block # fully open posture# Only allow SSH to *.internal and HTTPS to the 10.x network
authunnel-server --allow '*.internal:22' --allow '10.0.0.0/8:443'
# Or via environment variable (comma-separated)
ALLOW_RULES='*.internal:22,10.0.0.0/8:443' authunnel-server
# IPv6 example
authunnel-server --allow '[::1]:22' --allow '[2001:db8::1]:443'
# Explicit open mode (no allowlist) — only if arbitrary egress from the
# server host is genuinely acceptable for the deployment
authunnel-server --allow-open-egressThis 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.
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.
For managed client mode, register a public OIDC client with:
- standard authorization code flow enabled
- PKCE required with
S256 - loopback redirect URIs allowed for
http://127.0.0.1/*or for a specific fixed callback such ashttp://127.0.0.1:38081/callback - refresh tokens enabled
- scopes that include
openidandoffline_access - an access-token audience that includes the Authunnel resource, for example
authunnel-server
Some providers, including Auth0 custom APIs, require an explicit audience/resource parameter on the authorization request. Use --oidc-audience in those environments.
Some providers require an exact loopback callback URL instead of allowing a random local port. Use --oidc-redirect-port when you need to register a fixed callback URL in the IdP.
Some providers require extra configuration before offline_access can be requested successfully. When that is not configured, override the client with --oidc-scopes openid and rely on cached access tokens only.
Run the fast suite:
go test ./...Current fast coverage includes:
- client config validation for manual vs managed auth modes
- token cache reuse, mismatch rejection, and refresh-before-browser behavior
- PKCE callback state validation and stderr-only auth messaging
- SOCKS5 CONNECT request construction and handshake behavior
- bidirectional proxy forwarding behavior
- server authorization-header rejection and JWT audience validation
- WebSocket multiplexing: binary data round-trip, control message routing, interleaved text/binary frame handling, bidirectional control messages
- transport hardening: insecure OIDC issuer and tunnel URL rejection, secure-scheme enforcement on client and server
- token validation:
nbfnot-before enforcement,iatsanity check, non-emptysubrequirement, refresh subject pinning, refresh deadline enforcement - admission controls: global concurrent cap, per-user concurrent cap, per-user rate limiting (fake-clock deterministic), dial timeout against blackholed destinations, handler-level rejection with correct HTTP status and
Retry-After - egress posture: startup rejection when neither
--allowrules nor--allow-open-egressis present, mutual exclusion between the two modes, env-var equivalents - filesystem safety: unix socket directory permission checks (group/world-writable rejection, foreign-owner rejection), stale-socket cleanup refusal on non-socket paths, umask-tightened socket creation, token cache and lock directory safety
Developers need Go 1.26.3+ to build and test Authunnel from source.
The codebase is intentionally split so the moving parts of the auth and tunnel flows are easy to locate:
client/client.go- CLI parsing
- ProxyCommand and unix-socket tunnel setup
- SOCKS5 client-side handshake and byte forwarding
client/auth.go- auth-mode abstraction
- OIDC discovery, refresh, and Authorization Code + PKCE flow
- token cache and lock-file coordination for concurrent
sshinvocations
internal/tunnelserver/tunnelserver.go- issuer discovery and JWKS-backed JWT validation
- HTTP route setup for protected endpoints
- websocket-to-SOCKS bridge wiring
- connection longevity management: token-expiry and max-duration enforcement, token refresh validation with subject pinning
internal/wsconn/wsconn.goMultiplexConnadapter: wraps a*websocket.Connasnet.Connfor binary SOCKS5 data, routing text frames to a control channel for longevity messages (expiry warnings, disconnect, token refresh)
When changing the auth flow, keep these invariants intact:
- ProxyCommand mode must only write transport bytes to
stdout; any user-facing auth output belongs onstderr. - Managed OIDC mode must prefer cache, then refresh, then browser login, so repeated
sshruns stay fast and predictable. - Server-side authorization must continue to fail closed on missing bearer token, invalid JWT signature, wrong issuer, expired token, wrong
aud, missingsub, futureiat, or (at admission) unreachednbf. - Token refresh over the control channel must verify that the new token's subject matches the original tunnel's subject (subject pinning) and that its
nbf, if in the future, is at or before the current enforced connection deadline (exp + --expiry-grace), so the handover stays within the deadline the operator has already opted into. Never send refresh tokens to the server; only access tokens travel over the control channel.
The repository includes a Keycloak-based development environment under testenv/keycloak/.
docker compose -f testenv/keycloak/docker-compose.yml up -dThis imports a realm with:
- realm:
authunnel - issuer:
http://127.0.0.1:18080/realms/authunnel - public client:
authunnel-cli - bearer-only resource client:
authunnel-server - test user:
dev-user/dev-password
export OIDC_ISSUER='http://127.0.0.1:18080/realms/authunnel'
export INSECURE_OIDC_ISSUER=true # local Keycloak uses HTTP
export TOKEN_AUDIENCE='authunnel-server'
export TLS_CERT_FILE='../cert.pem'
export TLS_KEY_FILE='../key.pem'
cd server
# Local dev environment — opt into open egress since the destinations
# exercised by the example commands are loopback services
CGO_ENABLED=0 go run . --allow-open-egresscd client
CGO_ENABLED=0 SSL_CERT_FILE=../cert.pem go run . \
--tunnel-url https://localhost:8443/protected/tunnel \
--oidc-issuer http://127.0.0.1:18080/realms/authunnel \
--insecure-oidc-issuer \
--oidc-client-id authunnel-cli \
--oidc-scopes openid \
--unix-socket /tmp/authunnel/proxy.sockDirect ProxyCommand-compatible invocation:
SSL_CERT_FILE=../cert.pem ./client/client \
--tunnel-url https://localhost:8443/protected/tunnel \
--oidc-issuer http://127.0.0.1:18080/realms/authunnel \
--insecure-oidc-issuer \
--oidc-client-id authunnel-cli \
--oidc-scopes openid \
--proxycommand localhost 22Or via socat + unix-socket mode:
socat - SOCKS5:/tmp/authunnel/proxy.sock:localhost:22An opt-in Keycloak-backed end-to-end test is available:
AUTHUNNEL_E2E=1 go test ./client -run TestKeycloakProxyCommandManagedOIDCE2E -count=1The GitHub Actions workflow in .github/workflows/keycloak-e2e.yml starts Keycloak from testenv/keycloak/docker-compose.yml and runs that test in CI.
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.