Configuration reference
Everything is an environment variable, so the same build runs unchanged on Cloudflare Workers, AWS Lambda, a container and a laptop. Two rules shape all of it: local development works with no configuration at all, and the moment a real hostname is in play every development default becomes a hard error rather than a quiet weakness.
Relying party and upstream provider variables have their own pages: Relying parties and Upstreams.
Identity of the deployment
| Variable | Default | Meaning |
|---|---|---|
SAG_ISSUER | derived from the request in development | The iss claim and the base for every URL. No trailing slash, no query |
SAG_SECRET | a well-known development value | Master secret. 48 random bytes. Protects sessions, transactions and codes |
SAG_SECRET_PREVIOUS | - | The secret being retired, so a rotation does not sign everybody out. See Operations |
SAG_DEV | true for localhost, .localhost, .local and .linux.test issuers | Forces development mode on or off |
LOG_LEVEL | debug in development, info otherwise | debug, info, warn, error, silent |
Signing
| Variable | Default | Meaning |
|---|---|---|
SIGNING_BACKEND | local | local, cloudflare-hsm, aws-kms |
SIGNING_ALG | ES256 | The primary id_token algorithm |
SIGNING_ADDITIONAL_ALGS | - | Published alongside, so relying parties migrate one at a time |
SIGNING_PRIVATE_JWK | - | The key, as JSON. Also _<ALG> suffixed, for example SIGNING_PRIVATE_JWK_ML_DSA_44 |
SIGNING_PRIVATE_KEY_PEM | - | The same thing in PEM |
SIGNING_KMS_KEY_ID, SIGNING_KMS_REGION | - | With aws-kms |
HSM_BINDING, HSM_URL, HSM_SHARED_SECRET | HSM | With cloudflare-hsm. See Deployment |
SIGNING_PUBLIC_JWKS_EXTRA | [] | Extra public keys to publish, for a migration in progress |
REQUIRE_POST_QUANTUM_SIGNING | false | Refuse to start unless a post-quantum algorithm is configured. The older SIGNING_REQUIRE_POST_QUANTUM name still works |
Peer deployments (JWKS federation)
Only relevant to more than one instance answering as the same issuer. See Multi-region: listing a peer here trusts its keys as fully as this instance's own.
| Variable | Default | Meaning |
|---|---|---|
PEER_JWKS_URLS | - | The full JWKS URL of every other instance of this issuer. Empty means this feature is off and /jwks.json is exactly this instance's own keys |
PEER_JWKS_CACHE_TTL | 300 | Seconds before a healthy peer is refetched |
PEER_JWKS_STALE_TTL | Twice SESSION_MAX_LIFETIME | Seconds a peer's last known keys are still served, and still counted in /jwks.json, after it stops answering |
PEER_JWKS_TIMEOUT_MS | 4000 | |
PEER_JWKS_MAX_BYTES | 65536 | A peer's response over this size is refused rather than parsed |
PEER_JWKS_CACHE_BACKEND | memory | memory, cf-kv, dynamodb. memory does not survive an isolate or container restart - see Multi-region |
PEER_JWKS_CACHE_KV_BINDING | SAG_PEER_JWKS | With cf-kv |
PEER_JWKS_CACHE_TABLE, PEER_JWKS_CACHE_REGION | - | With dynamodb |
Sessions
| Variable | Default | Meaning |
|---|---|---|
SESSION_SCOPE | shared | shared for one session across every relying party, rp for one each |
SESSION_COOKIE_NAME | sag_session | |
SESSION_TTL | 43200 (12 hours) | Idle timeout |
SESSION_MAX_LIFETIME | 604800 (7 days) | Absolute lifetime, regardless of activity |
PROMPT_NONE_SHARED_SESSION | true | Whether prompt=none may be answered from the shared session when sessions are per relying party |
PROMPT_CONSENT_MODE | continue | continue shows "continue as ..."; off ignores prompt=consent |
LOGOUT_CONFIRM | auto | auto asks when the session is shared, always, never |
Tokens and codes
| Variable | Default | Meaning |
|---|---|---|
CODE_TTL | 60 | Authorisation code lifetime, in seconds |
TRANSACTION_TTL | 900 | How long an in-flight sign-in may take |
ID_TOKEN_TTL | 300 | |
ACCESS_TOKEN_TTL | 600 | The access token is only accepted by SAG's own /userinfo |
CLOCK_SKEW | 60 | Tolerance when checking times |
SUBJECT_TYPE | public | public gives every relying party the same sub, an HKDF of SUBJECT_SALT over the string public and the address; pairwise gives each one a different sub, the same HKDF with the relying party's sector in place of public |
SUBJECT_SALT | - | Always required; development falls back to a well-known salt and says so. Rotation warning: a new salt orphans every account at every relying party |
SANITISE_PLUS_EMAILS | true | Treat jamie+shop@example.com as jamie@example.com for identity: one mailbox, one person. Overridable per relying party with CLIENT_<SLUG>_SANITISE_PLUS_EMAILS |
A sub is derived from the verified email address, never from the upstream's
own subject, so somebody who moves between upstream providers - or falls back
to an email code - is the same person throughout. What that costs when
somebody's address changes is in
ADR 0011.
A relying party's sector is its declared sector_identifier, or its client id.
Nothing is inferred from its redirect URIs, so a relying party that moves where
it redirects keeps its accounts, and a group of applications that means to
share one account says so by declaring the same sector_identifier.
The issuer is deliberately not in the derivation: a relying party stores iss
alongside sub and already separates two deployments by it, so renaming this
one does not orphan anybody. The salt is the only thing that must never change.
SANITISE_PLUS_EMAILS decides identity only. OTP send limits always count the
untagged mailbox, whatever it is set to, because otherwise a new tag on every
attempt would walk straight past them.
The state store
See State and limits for what it is for and which backend to pick.
| Variable | Default | Meaning |
|---|---|---|
STATE_STORE_BACKEND | none | none, memory, cf-durable-object, dynamodb |
STATE_STORE_DO_BINDING | SAG_STATE | Durable Object namespace binding |
STATE_STORE_TABLE, STATE_STORE_REGION | - | With dynamodb |
STATE_STORE_MAX_ENTRIES | 10000 | Cap on the in-memory backend. A full store refuses a code claim rather than forgetting one |
REQUIRE_STATE_STORE | false | Refuse to start unless STATE_STORE_BACKEND names a real backend, so a template or a Terraform refactor cannot drop it silently |
The older REPLAY_STORE_* names still work and mean the same thing.
Pointing AWS somewhere else
SAG signs its own requests to KMS, DynamoDB and S3 rather than carrying an SDK, so an endpoint is only a base URL. Set one and that service is addressed there instead of at AWS - a local stack, DynamoDB Local, MinIO, or any S3-compatible bucket. The names are the AWS SDK's own, so an environment already configured for an emulator needs nothing further.
| Variable | Default | Meaning |
|---|---|---|
AWS_ENDPOINT_URL | - | Applies to KMS, DynamoDB and S3. Not SES, which always talks to the real regional endpoint |
AWS_ENDPOINT_URL_KMS | - | Overrides the above, for KMS alone |
AWS_ENDPOINT_URL_DYNAMODB | - | The state store |
AWS_ENDPOINT_URL_S3 | - | The relying party store, which becomes path style: <endpoint>/<bucket>/<key> |
The region still matters when an endpoint is set, because it is part of the signature's scope: whatever is answering has to agree about it.
A plain http endpoint is refused outside development. SAG's requests would
still be unforgeable, but a KMS reply travelling in clear is a signature
anybody on the path can replace, and an S3 reply is the relying party register
itself.
test/local-stack/ is a worked example: an instance signing with KMS, counting in DynamoDB and reading its clients from a bucket, all of it local.
Email codes
| Variable | Default | Meaning |
|---|---|---|
OTP_ENABLED | true | |
OTP_CODE_LENGTH | 9 | Minimum 9; anything lower is raised to 9 with a warning. OTP_DIGITS is accepted as the older name, and implies a numeric code |
OTP_CODE_ALPHABET | alphanumeric | alphanumeric (30 symbols, no confusable characters) or numeric |
OTP_TTL | 600 | |
OTP_MAX_ATTEMPTS | 5 | Per transaction, and see the honesty note in Limitations |
OTP_MAX_RESENDS | 3 | Per transaction |
OTP_SEND_WINDOW | 600 | The rate limit window, in seconds. Needs a state store. OTP_SEND_MIN_INTERVAL is the older name |
OTP_SEND_BURST | 2 | Codes to one address within a window |
OTP_SEND_DAILY_LIMIT | 5 | Codes a day to one address. Needs a state store |
OTP_ALLOWED_DOMAINS | - | An allow list. When set, it is exclusive |
OTP_BLOCKED_DOMAINS | - | Always applied |
SIGNIN_UNKNOWN_ADDRESS | silent | silent shows the code screen for an address no route can serve; explain says so |
Sending the mail
| Variable | Meaning |
|---|---|
EMAIL_PROVIDER | console, ses, notify, mailchannels, cloudflare, smtp |
EMAIL_FROM | Sign in <no-reply@id.example.com> |
EMAIL_REPLY_TO, EMAIL_OTP_SUBJECT | Optional |
SES_REGION, SES_CONFIGURATION_SET | AWS SES |
NOTIFY_API_KEY, NOTIFY_TEMPLATE_ID, NOTIFY_BASE_URL | GOV.UK Notify, which owns the wording of the message |
MAILCHANNELS_ENDPOINT, MAILCHANNELS_API_KEY | MailChannels, for Workers |
CLOUDFLARE_EMAIL_BINDING, CLOUDFLARE_EMAIL_DESTINATION | Cloudflare Email Routing |
SMTP_URL | smtps://user:pass@host:465 |
console only prints codes to the log, so it is refused outside development.
Relying party store
| Variable | Default | Meaning |
|---|---|---|
CLIENTS_STORE_BACKEND | none | none, file, cf-kv, s3 |
CLIENTS_STORE_DIR | <SAG_DATA_DIR>/clients | With file: the directory of <client id>.json records |
CLIENTS_STORE_KV_BINDING | SAG_CLIENTS | With cf-kv |
CLIENTS_STORE_S3_BUCKET, CLIENTS_STORE_S3_REGION | - | With s3 |
CLIENTS_STORE_PREFIX | clients/, or empty with file | Key prefix within the store |
CLIENTS_STORE_CACHE_TTL | 60 | Seconds a record is cached. "No such client" is cached too, but for at most ten seconds, so a record added a moment ago is not refused for a minute |
CLIENTS_OPAQUE_ENABLED | true | Whether store-held clients are accepted at all |
CLIENTS_CIMD_ENABLED | true | Client ID Metadata Documents |
CLIENTS_CIMD_ALLOWED_DOMAINS | - | Empty means any origin |
CLIENTS_CIMD_ALLOW_SUBDOMAINS | true | |
CLIENTS_CIMD_CACHE_TTL | 300 | |
CLIENTS_CIMD_MAX_BYTES | 32768 | Size cap on a fetched document |
Appearance and legal links
See Branding.
| Variable | Default | Meaning |
|---|---|---|
UI_TITLE | Sign in | |
UI_ORG_NAME, UI_LOGO_URL | - | The operator's own identity, shown in the header |
UI_BRAND_NAME, UI_PRODUCT_NAME, UI_BRAND_URL | RESOAuth, Smart Access Gateway, https://resoauth.dev | |
UI_WHITELABEL | false | Drops the product name, keeps the attribution |
UI_TERMS_URL, UI_PRIVACY_URL | - | Instance-wide, overridden per relying party |
UI_SUPPORT_URL | - | "Get help signing in" |
UI_LOCALE | en-GB | The document language |
CUSTOM_CSS_SNIPPET | - | Served from /static/custom.css, after the stylesheet |
CUSTOM_CSS_REMOTE_URL | - | Replaces the default stylesheet. Must be https |
The colour theme control on the pages needs nothing configured: it is built by
/static/sag.js and the choice lives in the person's own browser.
Profile claims
See Profile claims for what is relayed, what is guessed, and why guessing is off by default.
| Variable | Default | Meaning |
|---|---|---|
PROFILE_CLAIMS | name given_name family_name preferred_username picture locale | Which OpenID Connect profile claims may be relayed from an upstream. Anything not named is dropped |
PROFILE_PICTURE | true | Separate from the list above, because it is the one claim that makes a relying party's page fetch from a third party |
PROFILE_NAME_FROM_EMAIL | off | infer guesses a display name from the local part of the address, for the email code path where there is no upstream. Emitted alongside urn:sag:name_inferred |
PROFILE_AVATAR_FALLBACK | off | initials draws an initials avatar as a data: URI when there is no upstream picture. Asks no avatar service anything |
PROFILE_SHOW_ON_SCREEN | true | Whether SAG's own screens show the name and picture they hold |
Routing an address to a provider
See Upstreams.
| Variable | Default | Meaning |
|---|---|---|
SIGNIN_PROVIDER_HINT | select | What to do when more than one upstream could take an address. select reads the domain's mail records and goes straight there; order shows the chooser with that option first; off never looks |
DNS_RESOLVER_URL | https://cloudflare-dns.com/dns-query | DNS-over-HTTPS, used only where the platform has no resolver. Workers and Lambda have none; the Node adapter supplies the host's own, and then no query leaves the deployment |
DNS_BINDING | SAG_DNS | Where an adapter puts a platform resolver |
DNS_TIMEOUT_MS | 1500 | A lookup that does not answer is simply not an answer |
DNS_CACHE_TTL | 3600 | Seconds an answer is cached, per instance. A domain that matched nothing is cached for five minutes |
Authentication context
| Variable | Default | Meaning |
|---|---|---|
ACR_DEFAULT_REQUIRED | - | A floor for every relying party, applied whether or not they ask |
The values SAG understands, weakest first:
urn:sag:acr:email-otp a code sent to an address
urn:sag:acr:federated an upstream identity provider
urn:sag:acr:federated-mfa the upstream reported multi-factor
A request that asks for more than the sign-in achieved is refused with
unmet_authentication_requirements rather than quietly answered with
something weaker.