A lightweight TOTP (Time-based One-Time Password) authentication gateway written in Go. It sits in front of any HTTP service, requiring users to enter a valid TOTP code before granting access.
- TOTP Authentication — RFC 6238 compliant, supports SHA1, SHA256, and SHA512
- Reverse Proxy — forwards authenticated requests to an upstream service
- Session Management — HMAC-signed cookies with sliding expiration and max lifetime
- Rate Limiting — per-IP rate limiting to prevent brute-force attacks
- Secret Loading — supports both file-based secrets (Docker secrets) and environment variables
- Graceful Shutdown — handles SIGINT/SIGTERM with configurable timeout
- Zero Dependencies — uses only Go standard library
Download pre-compiled binaries from the GitHub Releases page. Binaries are available for linux/amd64 and linux/arm64. Each release includes SHA256 checksums for verification.
# Download and verify
curl -LO "https://github.com/johnwmail/totp-gate/releases/latest/download/totp-gate-linux-amd64"
curl -LO "https://github.com/johnwmail/totp-gate/releases/latest/download/totp-gate-linux-amd64.sha256"
sha256sum -c totp-gate-linux-amd64.sha256
chmod +x totp-gate-linux-amd64
# Run
TOTPGATE_TOTP_SECRET="JBSWY3DPEHPK3PXP" ./totp-gate-linux-amd64go build -ldflags "-s -w -X main.Version=v1.0.0" -o totp-gate .
TOTPGATE_TOTP_SECRET="JBSWY3DPEHPK3PXP" ./totp-gateAccess the gateway at http://localhost:8080. It will proxy requests to the upstream service after successful TOTP authentication.
All configuration is done via environment variables:
| Variable | Default | Description |
|---|---|---|
TOTPGATE_AUTH_LISTEN |
0.0.0.0:8080 |
Address to listen on (port or ip:port) |
TOTPGATE_UPSTREAM |
http://localhost:3000 |
Upstream service URL to proxy to |
TOTPGATE_TARGETS |
(empty) | Multi-target routing: host=upstream,host/path-prefix=upstream,default=upstream. Overrides TOTPGATE_UPSTREAM. |
TOTPGATE_AUTH_DISABLED |
false |
Disable authentication (bypass mode) |
TOTPGATE_TOTP_SECRET |
(required) | Base32-encoded TOTP secret (fallback) |
TOTPGATE_TOTP_SECRET_FILE |
/run/secrets/totpgate_totp_secret |
Path to file containing TOTP secret |
TOTPGATE_TOTP_PERIOD |
30 |
TOTP time step in seconds |
TOTPGATE_TOTP_DIGITS |
6 |
Number of digits in TOTP code |
TOTPGATE_TOTP_ALGORITHM |
SHA1 |
Hash algorithm: SHA1, SHA256, SHA512 |
TOTPGATE_AUTH_COOKIE_TTL |
86400 |
Max session lifetime in seconds (24h) |
TOTPGATE_AUTH_COOKIE_SECURE |
true |
Set Secure flag on cookies (set false for local dev/test with HTTP-only) |
TOTPGATE_AUTH_REFRESH_INTERVAL |
600 |
Max inactivity window in seconds (10m) |
TOTPGATE_TRUSTED_PROXIES |
(see below) | Comma-separated trusted proxy IPs or CIDRs for forwarded-header trust |
TOTPGATE_INSECURE_SKIP_VERIFY |
false |
Skip TLS certificate verification for https:// upstream targets (dev/testing only) |
The TOTP secret is loaded in this order:
- File specified by
TOTPGATE_TOTP_SECRET_FILE(default:/run/secrets/totpgate_totp_secret) - Environment variable
TOTPGATE_TOTP_SECRET
Using file-based secrets is recommended for production, especially with Docker secrets.
The X-Real-IP and X-Forwarded-For headers are only trusted when the immediate peer matches a configured trusted proxy.
- Default (env var not set):
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - When set: specified values +
127.0.0.1(always included) - Examples:
- Cloudflare:
TOTPGATE_TRUSTED_PROXIES="173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" - Local nginx: use defaults (nginx on
127.0.0.1is already trusted)
- Cloudflare:
If the request comes from an untrusted peer (e.g., direct internet connection), these headers are ignored and r.RemoteAddr is used.
When routing to https:// upstream targets, totp-gate validates TLS certificates against the system CA store by default. For internal services with self-signed or private CA certificates, set:
TOTPGATE_INSECURE_SKIP_VERIFY=trueWhen TOTPGATE_TARGETS is set, the gateway routes requests to different upstream services based on the Host header and optionally the request path prefix.
TOTPGATE_TARGETS="app1.example.com=http://localhost:3000,app2.example.com=http://localhost:4000" ./totp-gateRoute different paths on the same host to different backends:
TOTPGATE_TARGETS="app.example.com/api=http://api:8080,app.example.com/static=http://cdn:9000,app.example.com=http://web:3000" ./totp-gateUse the special default key to route unmatched requests to a fallback upstream:
TOTPGATE_TARGETS="app.example.com/api=http://api:8080,default=http://fallback:3000" ./totp-gateKey format:
| Key | Matches |
|---|---|
host |
All requests on that host |
host/path-prefix |
Requests on that host whose path starts with the prefix |
default |
All requests not matched by any other rule |
Routing rules:
- The port is stripped from the
Hostheader before matching (e.g.,app.example.com:8080→app.example.com). - More specific (longer) rules take precedence —
/api/v2wins over/api, which wins over a host-only entry. /apifoodoes not match the prefix/api— the next character after the prefix must be/or end-of-string.- Duplicate keys (same host+prefix) cause a startup error.
- If no rule matches and no
defaultis set, the request returns HTTP 404. - WebSocket upgrades are fully supported and routed to the correct backend.
TOTPGATE_UPSTREAMis ignored whenTOTPGATE_TARGETSis set.
Docker Compose example:
services:
totp-gate:
image: johnwmail/totp-gate:latest
ports:
- "8080:8080"
environment:
TOTPGATE_TARGETS: "app.example.com/api=http://api:8080,app.example.com=http://web:3000,default=http://fallback:9000"
secrets:
- totpgate_totp_secret
secrets:
totpgate_totp_secret:
file: ./secret.txtClient → totp-gate (:8080) → Upstream Service (:3000)
↑
TOTP Gate
Client → totp-gate (:8080) ──Host: app.example.com/api──→ API Service (:8080)
↑ └──Host: app.example.com/static──→ CDN (:9000)
TOTP Gate └──Host: app.example.com──→ Web Service (:3000)
└──(no match)──→ 404 (or default fallback)
- User accesses the gateway
- If no valid session cookie exists, user is redirected to
/totp-gate/login - User enters their TOTP code
- On success, a signed session cookie is set and user is redirected to the upstream service
- Subsequent requests validate the cookie and proxy to upstream
- Max Lifetime: Sessions expire after
TOTPGATE_AUTH_COOKIE_TTL(default 24h) from login time - Inactivity Window: Sessions expire after
TOTPGATE_AUTH_REFRESH_INTERVALwithout any authenticated request - Security: Cookies are HMAC-signed with a key derived from the TOTP secret + random nonce (regenerated on each restart, invalidating all sessions)
| Path | Description |
|---|---|
/health |
Health check, returns OK |
/totp-gate/login |
TOTP login page (GET) and submission (POST) |
/ |
Authenticated reverse proxy |
Built-in per-IP rate limiting: 5 attempts per minute. Exceeding this returns HTTP 429.
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod .
RUN go mod download
COPY . .
RUN go build -o totp-gate .
FROM alpine:latest
COPY --from=builder /app/totp-gate /totp-gate
EXPOSE 8080
CMD ["/totp-gate"]Run with Docker secrets:
echo "JBSWY3DPEHPK3PXP" | docker secret create totpgate_totp_secret -
docker service create \
--name totp-gate \
--secret totpgate_totp_secret \
-p 8080:8080 \
-e TOTPGATE_UPSTREAM=http://myapp:3000 \
totp-gateOr with multi-target routing (path-prefix example):
docker run -d \
--name totp-gate \
-p 8080:8080 \
-e TOTPGATE_TARGETS="app.example.com/api=http://api:8080,app.example.com=http://web:3000" \
-e TOTPGATE_TOTP_SECRET="JBSWY3DPEHPK3PXP" \
johnwmail/totp-gate:latestMIT License — see LICENSE