Documentation · version 2.1
Configure it
in one file.
Everything in the open source edition: installing OAUTH2Relay, running it, and every setting in its JSON configuration file. For deployment, monitoring and troubleshooting, see the operations guide.
Getting started
Quick start
OAUTH2Relay needs a token endpoint and the API you want to call. This example uses the client credentials grant and takes the client secret from an environment variable.
- Download the archive for your platform from the download section and put the
oauth2relaybinary on your path. - Write
config.json:
{
"port": 8080,
"host": "127.0.0.1",
"login": {
"url": "https://auth.example.com/oauth/token",
"grant_type": "client_credentials",
"client_id": "my-service",
"client_secret": "${env:API_CLIENT_SECRET}"
},
"relay": {
"mode": "passthrough",
"url": "https://api.example.com/v1",
"method": "inherit",
"headers": { "Authorization": "Bearer ${ACCESSTOKEN}" }
}
}
- Check it, start the relay, and call the API through it:
$ oauth2relay --check-config --config config.json
config.json: configuration is valid
Secret placeholders were not resolved; they are checked when the relay starts.
$ export API_CLIENT_SECRET='s3cr3t'
$ oauth2relay --config config.json
level=INFO msg="Starting OAUTH2Relay" version=2.1.0 log_level=info
level=INFO msg="Initial access token obtained"
level=INFO msg="Relay server listening" addr=127.0.0.1:8080 relay_mode=passthrough tls=false
$ curl http://127.0.0.1:8080/orders?status=open
# relayed as GET https://api.example.com/v1/orders?status=open
# with "Authorization: Bearer <token>"
Your services now call http://127.0.0.1:8080 instead of the API, with no token handling of their own. The relay renews the token in the background before it expires.
Installation
OAUTH2Relay is a single static binary with no dependencies. Each release has an archive for Linux, macOS and Windows on x86-64 and ARM64, and a SHA256SUMS file. Each archive contains the binary, the licence and notices, this documentation in Markdown, and a systemd unit in deploy/systemd/.
Linux
$ curl -LO https://oauth2relay.com/downloads/v2.1.0/oauth2relay_2.1.0_linux_amd64.tar.gz
$ curl -LO https://oauth2relay.com/downloads/v2.1.0/SHA256SUMS
$ sha256sum -c SHA256SUMS --ignore-missing
oauth2relay_2.1.0_linux_amd64.tar.gz: OK
$ tar xzf oauth2relay_2.1.0_linux_amd64.tar.gz
$ sudo install -m 0755 oauth2relay_2.1.0_linux_amd64/oauth2relay /usr/local/bin/oauth2relay
To run it as a service, use the hardened systemd unit in the archive; the operations guide has the installation steps.
macOS
Use shasum -a 256 -c SHA256SUMS --ignore-missing to verify the download. The binary is not signed with an Apple Developer ID, so macOS blocks it the first time. After checking the checksum, remove the quarantine flag: xattr -d com.apple.quarantine oauth2relay.
Windows
Extract the zip archive and run oauth2relay.exe --config config.json from a terminal. Check the download with Get-FileHash oauth2relay_2.1.0_windows_amd64.zip -Algorithm SHA256 and compare the result with SHA256SUMS.
Containers
Copy the Linux binary into a minimal image. The relay needs only CA certificates, which the distroless base provides, and runs as a non-root user:
FROM gcr.io/distroless/static-debian13:nonroot
COPY oauth2relay /usr/local/bin/oauth2relay
USER 65532:65532
EXPOSE 8080 9090
ENTRYPOINT ["/usr/local/bin/oauth2relay"]
CMD ["--config", "/etc/oauth2relay/config.json"]
Inside a container, set "host": "0.0.0.0" so the relay is reachable from outside it. Kubernetes probes and settings are in the operations guide.
Command line
oauth2relay [options] [config_file]
Options:
--config FILE Configuration file (default: config.json)
--check-config Check the configuration file and exit
--log-level LEVEL Override log_level: debug, info, warn or error
--log-format FORMAT Override log_format: text or json
-h, --help Show this help message
-v, --version Show version information
- Flags can be written with one or two dashes and can come before or after the configuration file. An unknown flag is an error (exit status 2).
--check-configreads and checks the whole configuration, prints<file>: configuration is validand exits with status 0, or prints the problem and exits with status 1. It also prints warnings for settings that work but are discouraged. It does not contact the identity provider or the secrets managers, and does not read${env:...}variables, so it is safe to run in CI.- On
SIGTERMorSIGINTthe relay stops accepting connections and finishes in-flight requests (see Server limits).
Configuration
Configuration file
The configuration is one JSON file. Unknown fields are rejected, so a misspelt setting stops the relay at startup instead of being silently ignored, and the whole file is checked when it is loaded. Only port, host, a relay target and a way to get a token (login or relay.fixedtoken) are required.
| Setting | Default | Description |
|---|---|---|
port | required | Port of the relay server, 1–65535. |
host | required | Address to listen on, e.g. 127.0.0.1, or 0.0.0.0 for all interfaces. |
apiKey | none | If set, callers must send a matching X-API-Key header or get 401. Compared in constant time and never forwarded. |
login | none | How to obtain the access token. See Login. |
refresh | none | How to renew it with a refresh token. See Refresh. |
relay | required | Where and how requests are relayed. See Relay. |
token | When the token is renewed. See Token lifetime. | |
token_wait_timeout | 30 | Seconds a request waits for a valid token before failing with 503. |
log_level, log_format, access_log, logfile | See Logging and errors. | |
server | Timeouts and shutdown. See Server limits. | |
max_request_body_bytes | 10485760 | Largest request body accepted (10 MiB); larger bodies get 413. |
admin_port, health, metrics | See Health checks and metrics. | |
tls, ca_file | HTTPS and extra CA certificates. See TLS. | |
trusted_proxies | none | Proxy or load balancer addresses (IPs or CIDR ranges). For requests from these, ${IP} is taken from X-Forwarded-For: the rightmost address that is not itself a trusted proxy. Otherwise the header is ignored, because callers could forge it. |
secrets_managers | none | See Secrets managers. |
secrets_refresh_interval | 0 (off) | Seconds between re-fetches of secrets, at least 60. See Rotating secrets. |
Logging and errors
Logs are structured and written to stdout, which suits containers and systemd.
log_level:debug,info(default),warnorerror.debugadds the content of token and relay requests and responses, with secrets still masked; use it only for short investigations.log_format:text(default,key=valuepairs) orjson(one object per line, for log collectors).access_log: log one line per relayed request (defaulttrue). Whenfalse, only requests the relay rejected or failed are logged.logfile: write logs to this file, created with mode0600, instead of stdout. Rotate it with logrotate'scopytruncate.debug: older setting;trueis the same as"log_level": "debug".
Each relayed request produces one request line with request_id, method, path, client_ip, status, duration_ms, bytes and upstream_host, plus error and detail when the relay itself failed the request:
level=WARN msg=request request_id=393e60c1... method=GET path=/slow client_ip=192.0.2.1 status=504 duration_ms=1001.7 bytes=134 upstream_host=api.example.com error=upstream_timeout detail="... context deadline exceeded ..."
Redaction. Every log line passes through a filter. Values from secrets managers and ${env:...}, and the configured apiKey, fixedtoken, password, client_secret, authstring, jwt_key and jwt_private_key, are replaced with [REDACTED]. Tokens are shown as their first 4 characters and length, e.g. eyJh…(len=1024). Sensitive form, JSON and header fields and PEM private keys are masked even when not otherwise known.
Request IDs. Every request gets an ID: the caller's X-Request-ID if it is 1–128 letters, digits, ., _, : or -, otherwise a generated one. It is returned in the X-Request-ID response header, logged, and sent to the API so both sides' logs can be matched. List it in relay.strip_headers to stop sending it.
Errors. Errors raised by the relay itself are JSON. Details such as an upstream address are logged under the request ID, not sent to the caller. Responses from the API, including its errors, are passed through unchanged.
{"error": "upstream_error", "message": "The upstream request failed", "request_id": "4f1982d5462943cf..."}
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | Malformed request, or an invalid target URL in parameter mode. |
| 401 | unauthorized | Missing or wrong X-API-Key. |
| 403 | forbidden | Target host not in allowed_hosts. |
| 403 | destination_blocked | parameter mode: the target resolves to an internal address not in allowed_networks. |
| 413 | request_too_large | Body larger than max_request_body_bytes. |
| 500 | token_error | No token could be obtained and retrying will not help, e.g. the identity provider rejected the credentials. |
| 500 | internal_error | Relay misconfiguration. |
| 502 | upstream_error | The relayed request failed (DNS, connection, TLS). |
| 502 | redirect_blocked | The API redirected somewhere not allowed. |
| 503 | token_unavailable | No token yet: the identity provider is unreachable or failing, or renewal took longer than token_wait_timeout. Retry later. |
| 504 | upstream_timeout | The API did not answer within relay.timeout. |
Server limits
The optional server section protects the relay from slow clients and controls shutdown. Values are in seconds except max_header_bytes.
"server": {
"read_header_timeout": 10,
"read_timeout": 60,
"write_timeout": 95,
"idle_timeout": 120,
"shutdown_timeout": 15,
"shutdown_delay": 0,
"max_header_bytes": 1048576
}
read_header_timeout: time to receive the request headers (default 10).read_timeout: time to receive the whole request, including the body (default 60).write_timeout: time to handle the request and write the response (defaulttoken_wait_timeout+relay.timeout+ 5). It must be at leasttoken_wait_timeout+relay.timeout.idle_timeout: how long an idle keep-alive connection stays open (default 120).shutdown_timeout: onSIGTERM, how long in-flight requests get to finish (default 15).shutdown_delay: seconds betweenSIGTERMand closing the listener (default 0). Readiness reportsdrainingmeanwhile, so load balancers stop sending traffic first. Keepshutdown_delay+shutdown_timeoutbelow the platform's grace period (30 seconds in Kubernetes).max_header_bytes: largest request header block accepted (default 1 MiB).
Health checks and metrics
Three operational endpoints, which do not need the API key:
| Path | Purpose |
|---|---|
/healthz | Liveness: 200 {"status":"ok"} while the process serves HTTP. |
/readyz | Readiness: 200 once the first token has been obtained (or fixedtoken is set); 503 while starting or shutting down. Also reports the token state (fixed, none, valid, expired or renewal_failed) and seconds to expiry, never the token. |
/metrics | Prometheus metrics. |
"admin_port": 9090,
"health": {"enabled": true, "liveness_path": "/healthz", "readiness_path": "/readyz"},
"metrics": {"enabled": true, "path": "/metrics"}
admin_port: serve these endpoints on a separate port (samehost). Recommended in production, so metrics are not reachable by relay callers. It starts listening before the first token is obtained, so liveness passes during a slow start-up.health.enabled,metrics.enabled: turn the endpoints off (defaulttrue).health.liveness_path,health.readiness_path,metrics.path: change the paths. Withoutadmin_port, these exact paths are answered by the relay and not relayed.
Readiness does not fail when a token renewal fails: requests renew the token themselves, so a replica taken out of the load balancer could never recover, and an identity provider outage affects every replica alike. Alert on the metrics instead.
Metric (prefix oauth2relay_) | Type | Meaning |
|---|---|---|
requests_total | counter | Relayed requests by code and upstream_host. 499 means the caller disconnected. |
request_duration_seconds | histogram | Time to handle a request, including waiting for a token. |
inbound_auth_failures_total | counter | Requests with a missing or wrong API key. |
token_renewals_total | counter | Token endpoint requests by type (login, refresh) and result. |
idp_request_duration_seconds | histogram | Time taken by token endpoint requests. |
token_expires_in_seconds | gauge | Seconds until the token expires (negative once expired). |
tls_certificate_expiry_timestamp_seconds | gauge | When the relay port's certificate expires. Only with tls. |
secret_refreshes_total | counter | Periodic secret re-fetches by result. |
secrets_last_refresh_timestamp_seconds | gauge | When secrets were last resolved. Only with secrets_managers. |
build_info | gauge | Always 1, with version and go_version labels. |
Go runtime and process metrics are included. Suggested alerts are in the operations guide.
Login
The login section says how to obtain an access token from the identity provider.
| Setting | Default | Description |
|---|---|---|
url | required | Token endpoint, http:// or https://. |
grant_type | custom | client_credentials, jwt, password, or empty for a custom request. See Grant types. |
client_id, client_secret | Client credentials, for the client_credentials and password grants. | |
method | Custom grant only: HTTP method of the token request (empty means GET; a body is sent only with POST). The standard grants always use POST. | |
headers | Extra headers for the token request. | |
parameters | standard names | Maps the relay's token fields to the names in the provider's JSON response: AccessToken (required), RefreshToken, ExpiresIn (seconds) and ExpiresAt (Unix time or RFC 3339, used when ExpiresIn is absent). Defaults to access_token, refresh_token and expires_in for the standard grants; the custom grant must set it. |
timeout | 10 | Seconds before one token request times out. |
retry_attempts | 5 | Total login attempts before giving up; 1 disables retries. |
retry_interval | 5 | Seconds before the first retry; each later wait doubles. |
retry_max_interval | 60 | Longest wait between retries. |
Retries
To renew a token the relay first tries a refresh and, if that fails, a login. A failed login is retried with exponential backoff, each wait shortened at random by up to half so that replicas do not retry in step. With the defaults the waits are about 5, 10, 20 and 40 seconds, and each failure is logged with the time of the next attempt.
Errors a retry cannot fix are not retried: a 4xx response (except 408 and 429), an OAuth error of invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type or invalid_scope, or a configuration error. Only one renewal runs at a time; requests wait for it for at most token_wait_timeout seconds and then get 503, while the renewal carries on in the background.
Grant types
Client credentials
The usual grant for server-to-server access. client_id and client_secret are sent in a form-encoded body.
"login": {
"url": "https://auth.example.com/oauth/token",
"grant_type": "client_credentials",
"client_id": "my-service",
"client_secret": "${env:API_CLIENT_SECRET}"
}
JWT bearer (RFC 7523)
The relay signs a JWT assertion and exchanges it for an access token, sent as grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the assertion in assertion. Use a shared secret (HMAC) or a private key, as Google service accounts and Salesforce do:
"login": {
"url": "https://oauth2.googleapis.com/token",
"grant_type": "jwt",
"jwt_algorithm": "RS256",
"jwt_private_key": "${vault_prod:svc/jwt:private_key}",
"jwt_key_id": "key-2026-10",
"jwt_issuer": "svc@project.iam.gserviceaccount.com",
"jwt_audience": "https://oauth2.googleapis.com/token",
"jwt_scope": "https://www.googleapis.com/auth/drive.readonly"
}
jwt_algorithm:HS256(default),HS384,HS512,RS256,RS384,RS512,PS256,PS384,PS512,ES256,ES384orES512.jwt_key: shared secret for theHS*algorithms.jwt_private_key: PEM private key (PKCS #1, PKCS #8 or SEC 1) forRS*,PS*andES*. A key on one line with literal\nsequences is accepted.jwt_private_key_file: path to a PEM key file, used whenjwt_private_keyis empty.jwt_issuer:issclaim (required).jwt_subject,jwt_audience,jwt_scope:sub,audandscopeclaims (optional).jwt_key_id:kidheader (optional).jwt_lifetime: seconds until the assertion expires (default 300).jwt_claims: extra claims, e.g.{"tenant": "acme"}; these override the claims above on a clash.jwt_grant_type: override thegrant_typevalue for endpoints that use a non-standard one.
Each assertion also carries iat, nbf, exp and a random jti. The key and claims are checked at startup, so an invalid key stops the relay before it serves.
Password
Not recommended. The relay holds a user's password and sends it to the identity provider. OAuth 2.0 Security Best Current Practice (RFC 9700) says this grant must not be used, and many providers have removed it. The relay logs a warning at startup when it is configured.
"login": {
"url": "https://auth.example.com/oauth/token",
"grant_type": "password",
"user": "username",
"password": "${env:API_PASSWORD}",
"client_id": "my-client",
"client_secret": "${env:API_CLIENT_SECRET}"
}
Custom
For providers with their own login format, leave grant_type empty and build the request yourself with method, headers and data. ${USER}, ${PASSWORD} and ${AUTHSTRING} refer to the user, password and authstring settings. parameters is required.
"login": {
"url": "https://saas.example.com/login",
"method": "POST",
"authstring": "${env:SAAS_BASIC_AUTH}",
"headers": { "Authorization": "Basic ${AUTHSTRING}" },
"data": "{\"scope\": \"read\"}",
"parameters": { "AccessToken": "AccessToken", "ExpiresIn": "ExpiresIn", "RefreshToken": "RefreshToken" }
}
Refresh
With a refresh section, the relay renews the token with the refresh token before falling back to a new login.
"refresh": {
"url": "https://auth.example.com/oauth/token",
"method": "POST",
"data": "grant_type=refresh_token&refresh_token=${REFRESHTOKEN}",
"headers": { "Content-Type": "application/x-www-form-urlencoded" }
}
url: refresh endpoint (required).method: HTTP method. Set it toPOST: empty meansGET, anddatais sent only withPOST.data: request body template;${REFRESHTOKEN}is the current refresh token.headers: extra headers.token: an initial refresh token, if you have one (optional).parameters: response field mapping, as for login (defaults to the standard names).timeout: seconds before a refresh request times out (default 10).
Token lifetime and background refresh
"token": {
"expiry_buffer": 10,
"default_lifetime": 300,
"background_refresh": true,
"refresh_at": 0.8
}
expiry_buffer: seconds subtracted from the lifetime the provider gives, so a token is not used right up to its expiry (default 10; at most half the lifetime of a short-lived token).default_lifetime: lifetime assumed when the response has no expiry field, or a negative or non-numeric one (default 300).background_refresh: renew the token before it expires, so requests do not wait for a renewal (defaulttrue).refresh_at: fraction of the lifetime after which the background refresh renews the token (default 0.8, between 0 and 1). A one-hour token is renewed after 48 minutes.
With background refresh a failed renewal does not affect requests: the current token is used until it expires, the renewal is retried with backoff, and the failure is logged and counted. If the token does expire, requests renew it themselves. Without background refresh the token is renewed only when a request finds it expired, and that request waits. Background refresh means one renewal per token lifetime even when the relay is idle.
Relay
The relay section says where requests go and what is added to them.
"relay": {
"mode": "passthrough",
"url": "https://api.example.com/v1",
"method": "inherit",
"timeout": 60,
"pushheaders": true,
"headers": {
"Authorization": "Bearer ${ACCESSTOKEN}",
"Accept": "application/json"
},
"parameters": {
"client_ip": "${IP}",
"country_code": "US"
}
}
| Setting | Default | Description |
|---|---|---|
mode | passthrough | single, passthrough or parameter, case-insensitive. See Relay modes. |
url | Target URL. Required in single and passthrough modes. | |
method | GET | HTTP method of relayed requests, or inherit to use each caller's method. |
headers | Headers added to relayed requests, usually Authorization: Bearer ${ACCESSTOKEN}. | |
parameters | Parameters merged into each relayed request. Keys are the outgoing names; values may use variables. | |
data | A fixed body for methods other than GET and HEAD, instead of the caller's. | |
timeout | 60 | Seconds before a relayed request times out with 504. A request is cancelled if the caller disconnects. |
fixedtoken | Use this token instead of logging in. | |
pushheaders | false | Forward the caller's headers. headers take precedence. Never forwarded: X-API-Key and hop-by-hop headers. Authorization and Cookie are forwarded only if listed in forward_headers. |
forward_headers | With pushheaders: forward only these headers (case-insensitive). | |
strip_headers | With pushheaders: headers never forwarded, in addition to the above. | |
follow_redirects | false | Follow up to 5 redirects that stay on the same host (or on allowed_hosts) and do not switch from https to http; others return 502. By default a 3xx is returned to the caller. |
url_param, allowed_hosts, allowed_networks | parameter mode only; see below. |
How parameters are merged. For POST, PUT and PATCH requests with a form-encoded or JSON body, the incoming query parameters and the configured parameters are merged into the body. For all other requests they are added to the target URL's query string; GET and HEAD carry no body, and other methods send relay.data if set or stream the caller's body through unchanged.
Relay modes
relay.mode decides where each request is sent. If the mode is not recognised, or a setting it needs is missing, the relay refuses to start and says why.
single
Every request goes to one fixed endpoint. The incoming path is ignored.
- In
localhost:8080/my_profile- Out
example.com/api/get_statuses
passthrough default
The incoming path is appended to the configured url.
- In
localhost:8080/my_profile- Out
example.com/api/my_profile
parameter
The caller names the target URL. The parameter is stripped before relaying.
- In
localhost:8080/?external_url=https://example.com/my_profile- Out
example.com/my_profile
"relay": {
"mode": "parameter",
"url_param": "external_url",
"allowed_hosts": ["example.com", "*.example.com"],
"allowed_networks": [],
"method": "GET",
"headers": { "Authorization": "Bearer ${ACCESSTOKEN}" }
}
In parameter mode the target URL can arrive in the query string, a form-encoded body or a JSON body, and is removed from all of them. The incoming path is never appended, and ${...} placeholders are never expanded in a caller's URL.
url_param: the incoming parameter holding the target URL (defaultexternal_url). Choose another name if the API has its ownexternal_urlparameter.allowed_hosts: required. Hosts the URL may point to, case-insensitive.*.example.commatches subdomains but notexample.comitself; an entry with a port matches that port only. Others get403 forbidden; a missing or non-http(s)URL gets400.allowed_networks: internal addresses or CIDR ranges the relay may connect to, e.g.["10.20.0.0/16"]. Keep it empty unless you need to reach internal services.
The token goes wherever the caller says. In parameter mode keep allowed_hosts narrow and set apiKey. The relay also refuses to connect to internal addresses: loopback, private networks (10/8, 172.16/12, 192.168/16, fc00::/7), link-local including the cloud metadata service at 169.254.169.254, carrier-grade NAT and other reserved ranges, with 403 destination_blocked. The check is made on the address actually connected to, after DNS resolution and on every redirect, so DNS rebinding cannot get round it. With an HTTP proxy, the addresses the name resolves to are checked before the request is handed to the proxy.
TLS
The relay port serves plain HTTP unless tls is set:
"tls": {
"cert_file": "/etc/oauth2relay/tls.crt",
"key_file": "/etc/oauth2relay/tls.key",
"min_version": "1.2"
},
"ca_file": "/etc/oauth2relay/corporate-ca.pem"
cert_file,key_file: PEM certificate chain and private key. Set both to serve HTTPS; plain HTTP to that port then gets400.min_version: oldest TLS version accepted,1.2(default) or1.3.ca_file: CA certificates added to the system ones for connections to the token endpoint and the API, e.g. for a corporate CA. Secrets managers use their own settings (for Vault,VAULT_CACERT);SSL_CERT_FILEreplaces the system CAs for every connection.
The certificate is reloaded within 10 seconds of either file changing, so certificates renewed by cert-manager or certbot are picked up without a restart; if the new files cannot be loaded, the current certificate stays in use. The admin port always serves plain HTTP. Without tls, run the relay behind a TLS-terminating proxy that is the only way to reach it; the relay warns at startup when it serves plain HTTP on a non-loopback address.
Variables
These runtime variables are replaced in URLs, headers, request bodies and parameters on every request:
| Variable | Value |
|---|---|
${ACCESSTOKEN} | The current access token. |
${REFRESHTOKEN} | The current refresh token. |
${IP} | The caller's IP address (see trusted_proxies). |
${FIXEDTOKEN} | The configured relay.fixedtoken. |
${USER}, ${PASSWORD}, ${AUTHSTRING} | The login.user, login.password and login.authstring settings. |
${DATA} | The configured data field. |
A ${NAME} that is not one of these is left as it is. Secret placeholders with a colon, ${alias:name} and ${env:NAME}, are different: they are resolved once, at startup.
Secrets managers
Fetch credentials from AWS Secrets Manager, HashiCorp Vault or Azure Key Vault instead of writing them in the file. Define each manager under an alias:
"secrets_managers": {
"aws_prod": { "type": "aws_secrets_manager", "region": "eu-west-1" },
"vault_prod": {
"type": "hashicorp_vault",
"address": "https://vault.example.com",
"auth": { "type": "approle", "role_id": "relay", "secret_id_env": "VAULT_SECRET_ID" }
},
"azure_prod": { "type": "azure_key_vault", "vault_url": "https://mykeyvault.vault.azure.net/" }
}
Then refer to a secret as ${alias:secret_name}, or ${alias:secret_name:json_key} to take one key of a JSON secret:
"client_secret": "${aws_prod:myapp/credentials:client_secret}",
"headers": { "Authorization": "Basic ${vault_prod:svc:user}:${vault_prod:svc:pass}" }
Placeholders work in any string, including header and parameter maps, lists such as allowed_hosts, and jwt_claims. Each secret is fetched once however many placeholders use it, and the values are masked in logs. A JSON number, boolean or object is used as its JSON text.
| Type | Settings |
|---|---|
aws_secrets_manager | region; profile (optional). Uses the standard AWS credential chain, including IAM roles and IRSA. |
hashicorp_vault | address (default VAULT_ADDR); namespace; mount (default secret); kv_version 1 or 2 (default 2); auth: approle (role_id, secret_id_env), token (token_env, default VAULT_TOKEN) or kubernetes (role, token_path), each with an optional mount. Without auth, VAULT_TOKEN is used. |
azure_key_vault | vault_url; then tenant_id, client_id and client_secret_env for a service principal, client_id alone for a user-assigned managed identity, or none for DefaultAzureCredential. |
Environment variables
${env:NAME} is replaced with the environment variable NAME when the relay starts. It needs no secrets_managers entry and works anywhere a secret placeholder does.
$ export API_CLIENT_SECRET='s3cr3t'
$ grep client_secret config.json
"client_secret": "${env:API_CLIENT_SECRET}"
$ oauth2relay --config config.json
${env:NAME:json_key}reads one key from a variable that holds a JSON object.- If the variable is not set, the relay refuses to start and names it. A variable set to an empty string resolves to an empty string.
- Values are masked in logs like other secrets.
- Placeholders work in string values only, not in numbers such as
port. envis reserved and cannot be a secrets manager alias.
Environment variables suit containers and systemd, where the platform injects them from its own secret store: a Kubernetes Secret, EnvironmentFile= or docker run --env-file. They are read once at startup; for credentials that rotate without restarts, use a secrets manager with secrets_refresh_interval.
Rotating secrets
Secrets are fetched at startup. With secrets_refresh_interval (seconds, at least 60) the relay re-resolves every placeholder on that schedule and switches to the new values for later requests and renewals; requests in progress finish with the values they started with. If a re-fetch fails, the current values stay in use and it is retried at the next interval. The configuration file is not re-read, and each re-fetch logs in to the secrets manager again, so choose minutes rather than seconds.
The current token is kept when credentials change; the new ones are used at the next login. To rotate a client secret: add the new secret at the identity provider, update it in the secrets manager, wait one interval plus one token lifetime, then revoke the old one. The operations guide covers the other credentials.
Complete example
{
"port": 8443,
"host": "0.0.0.0",
"admin_port": 9090,
"log_format": "json",
"apiKey": "${env:RELAY_API_KEY}",
"tls": {
"cert_file": "/etc/oauth2relay/tls.crt",
"key_file": "/etc/oauth2relay/tls.key"
},
"server": { "shutdown_delay": 5 },
"secrets_managers": {
"vault": {
"type": "hashicorp_vault",
"address": "https://vault.example.com",
"auth": { "type": "kubernetes", "role": "oauth2relay" }
}
},
"secrets_refresh_interval": 900,
"login": {
"url": "https://auth.example.com/oauth/token",
"grant_type": "client_credentials",
"client_id": "orders-service",
"client_secret": "${vault:oauth2relay/orders:client_secret}"
},
"relay": {
"mode": "passthrough",
"url": "https://api.example.com/v1",
"method": "inherit",
"pushheaders": true,
"headers": { "Authorization": "Bearer ${ACCESSTOKEN}" }
}
}
More
Upgrade notes
Read the notes for every version between yours and the new one, and run oauth2relay --check-config with the new binary against your configuration before switching. The archive's UPGRADING.md has the full notes.
Upgrading to 2.1
- New
${env:NAME}placeholders. A string containing${env:...}, which 2.0 passed through unchanged, is now replaced, and the relay does not start if the variable is unset. - A secrets manager with the alias
envis rejected; rename it and its placeholders.
Upgrading to 2.0
2.0 is the first production release, with breaking changes from 1.3:
- Logs go to stdout and are structured (
key=valueor JSON), with one line per request. - Configuration is checked strictly: unknown fields, unsupported grant types and invalid URLs stop the relay. Unknown command-line flags are errors.
/healthz,/readyzand/metricsare answered by the relay; setadmin_portif callers relied on relaying those paths.- Identity provider outages return
503 token_unavailableinstead of500. parametermode blocks internal addresses; list them inallowed_networksif needed.- Tokens are renewed in the background at 80% of their lifetime, and login retries back off exponentially from 5 seconds.
Security
- Keep secrets out of the configuration file: use
${env:NAME}or a secrets manager. - Serve HTTPS with
tls, or put the relay behind a TLS-terminating proxy. - Set
apiKeyunless the network already limits who can reach the relay, and always inparametermode. - Use
admin_porton a private network for health checks and metrics. - Run with
log_levelinfoin production;debuglogs request content. - Prefer the
client_credentialsorjwtgrant topassword. - In
parametermode keepallowed_hostsnarrow andallowed_networksempty. - Rotate credentials regularly, and alert on failing token renewals.
Reporting a vulnerability: email info@fijomi.com rather than opening a public issue. Include the version, the configuration needed to reproduce the problem with secrets removed, and the steps. Security fixes are released for the latest version.