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.

  1. Download the archive for your platform from the download section and put the oauth2relay binary on your path.
  2. Write config.json:
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}" }
  }
}
  1. Check it, start the relay, and call the API through it:
terminal
$ 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

terminal
$ 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:

Dockerfile
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 --help
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-config reads and checks the whole configuration, prints <file>: configuration is valid and 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 SIGTERM or SIGINT the 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.

SettingDefaultDescription
portrequiredPort of the relay server, 1–65535.
hostrequiredAddress to listen on, e.g. 127.0.0.1, or 0.0.0.0 for all interfaces.
apiKeynoneIf set, callers must send a matching X-API-Key header or get 401. Compared in constant time and never forwarded.
loginnoneHow to obtain the access token. See Login.
refreshnoneHow to renew it with a refresh token. See Refresh.
relayrequiredWhere and how requests are relayed. See Relay.
tokenWhen the token is renewed. See Token lifetime.
token_wait_timeout30Seconds a request waits for a valid token before failing with 503.
log_level, log_format, access_log, logfileSee Logging and errors.
serverTimeouts and shutdown. See Server limits.
max_request_body_bytes10485760Largest request body accepted (10 MiB); larger bodies get 413.
admin_port, health, metricsSee Health checks and metrics.
tls, ca_fileHTTPS and extra CA certificates. See TLS.
trusted_proxiesnoneProxy 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_managersnoneSee Secrets managers.
secrets_refresh_interval0 (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), warn or error. debug adds the content of token and relay requests and responses, with secrets still masked; use it only for short investigations.
  • log_format: text (default, key=value pairs) or json (one object per line, for log collectors).
  • access_log: log one line per relayed request (default true). When false, only requests the relay rejected or failed are logged.
  • logfile: write logs to this file, created with mode 0600, instead of stdout. Rotate it with logrotate's copytruncate.
  • debug: older setting; true is 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:

log
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.

response
{"error": "upstream_error", "message": "The upstream request failed", "request_id": "4f1982d5462943cf..."}
StatuserrorMeaning
400bad_requestMalformed request, or an invalid target URL in parameter mode.
401unauthorizedMissing or wrong X-API-Key.
403forbiddenTarget host not in allowed_hosts.
403destination_blockedparameter mode: the target resolves to an internal address not in allowed_networks.
413request_too_largeBody larger than max_request_body_bytes.
500token_errorNo token could be obtained and retrying will not help, e.g. the identity provider rejected the credentials.
500internal_errorRelay misconfiguration.
502upstream_errorThe relayed request failed (DNS, connection, TLS).
502redirect_blockedThe API redirected somewhere not allowed.
503token_unavailableNo token yet: the identity provider is unreachable or failing, or renewal took longer than token_wait_timeout. Retry later.
504upstream_timeoutThe 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.

config.json
"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 (default token_wait_timeout + relay.timeout + 5). It must be at least token_wait_timeout + relay.timeout.
  • idle_timeout: how long an idle keep-alive connection stays open (default 120).
  • shutdown_timeout: on SIGTERM, how long in-flight requests get to finish (default 15).
  • shutdown_delay: seconds between SIGTERM and closing the listener (default 0). Readiness reports draining meanwhile, so load balancers stop sending traffic first. Keep shutdown_delay + shutdown_timeout below 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:

PathPurpose
/healthzLiveness: 200 {"status":"ok"} while the process serves HTTP.
/readyzReadiness: 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.
/metricsPrometheus metrics.
config.json
"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 (same host). 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 (default true).
  • health.liveness_path, health.readiness_path, metrics.path: change the paths. Without admin_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_)TypeMeaning
requests_totalcounterRelayed requests by code and upstream_host. 499 means the caller disconnected.
request_duration_secondshistogramTime to handle a request, including waiting for a token.
inbound_auth_failures_totalcounterRequests with a missing or wrong API key.
token_renewals_totalcounterToken endpoint requests by type (login, refresh) and result.
idp_request_duration_secondshistogramTime taken by token endpoint requests.
token_expires_in_secondsgaugeSeconds until the token expires (negative once expired).
tls_certificate_expiry_timestamp_secondsgaugeWhen the relay port's certificate expires. Only with tls.
secret_refreshes_totalcounterPeriodic secret re-fetches by result.
secrets_last_refresh_timestamp_secondsgaugeWhen secrets were last resolved. Only with secrets_managers.
build_infogaugeAlways 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.

SettingDefaultDescription
urlrequiredToken endpoint, http:// or https://.
grant_typecustomclient_credentials, jwt, password, or empty for a custom request. See Grant types.
client_id, client_secretClient credentials, for the client_credentials and password grants.
methodCustom grant only: HTTP method of the token request (empty means GET; a body is sent only with POST). The standard grants always use POST.
headersExtra headers for the token request.
parametersstandard namesMaps 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.
timeout10Seconds before one token request times out.
retry_attempts5Total login attempts before giving up; 1 disables retries.
retry_interval5Seconds before the first retry; each later wait doubles.
retry_max_interval60Longest 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.

client_credentials
"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:

jwt with a private key
"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, ES384 or ES512.
  • jwt_key: shared secret for the HS* algorithms.
  • jwt_private_key: PEM private key (PKCS #1, PKCS #8 or SEC 1) for RS*, PS* and ES*. A key on one line with literal \n sequences is accepted.
  • jwt_private_key_file: path to a PEM key file, used when jwt_private_key is empty.
  • jwt_issuer: iss claim (required).
  • jwt_subject, jwt_audience, jwt_scope: sub, aud and scope claims (optional).
  • jwt_key_id: kid header (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 the grant_type value 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.

password
"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.

custom
"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.

config.json
"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 to POST: empty means GET, and data is sent only with POST.
  • 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

config.json
"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 (default true).
  • 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.

config.json
"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"
  }
}
SettingDefaultDescription
modepassthroughsingle, passthrough or parameter, case-insensitive. See Relay modes.
urlTarget URL. Required in single and passthrough modes.
methodGETHTTP method of relayed requests, or inherit to use each caller's method.
headersHeaders added to relayed requests, usually Authorization: Bearer ${ACCESSTOKEN}.
parametersParameters merged into each relayed request. Keys are the outgoing names; values may use variables.
dataA fixed body for methods other than GET and HEAD, instead of the caller's.
timeout60Seconds before a relayed request times out with 504. A request is cancelled if the caller disconnects.
fixedtokenUse this token instead of logging in.
pushheadersfalseForward 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_headersWith pushheaders: forward only these headers (case-insensitive).
strip_headersWith pushheaders: headers never forwarded, in addition to the above.
follow_redirectsfalseFollow 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_networksparameter 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
parameter
"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 (default external_url). Choose another name if the API has its own external_url parameter.
  • allowed_hosts: required. Hosts the URL may point to, case-insensitive. *.example.com matches subdomains but not example.com itself; an entry with a port matches that port only. Others get 403 forbidden; a missing or non-http(s) URL gets 400.
  • 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:

config.json
"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 gets 400.
  • min_version: oldest TLS version accepted, 1.2 (default) or 1.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_FILE replaces 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:

VariableValue
${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:

config.json
"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:

config.json
"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.

TypeSettings
aws_secrets_managerregion; profile (optional). Uses the standard AWS credential chain, including IAM roles and IRSA.
hashicorp_vaultaddress (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_vaultvault_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.

terminal
$ 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.
  • env is 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

config.json
{
  "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 env is 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=value or 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, /readyz and /metrics are answered by the relay; set admin_port if callers relied on relaying those paths.
  • Identity provider outages return 503 token_unavailable instead of 500.
  • parameter mode blocks internal addresses; list them in allowed_networks if needed.
  • Tokens are renewed in the background at 80% of their lifetime, and login retries back off exponentially from 5 seconds.

Security

  1. Keep secrets out of the configuration file: use ${env:NAME} or a secrets manager.
  2. Serve HTTPS with tls, or put the relay behind a TLS-terminating proxy.
  3. Set apiKey unless the network already limits who can reach the relay, and always in parameter mode.
  4. Use admin_port on a private network for health checks and metrics.
  5. Run with log_level info in production; debug logs request content.
  6. Prefer the client_credentials or jwt grant to password.
  7. In parameter mode keep allowed_hosts narrow and allowed_networks empty.
  8. 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.