Skip to main content
Version: 0.2.0

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​

VariableDefaultMeaning
SAG_ISSUERderived from the request in developmentThe iss claim and the base for every URL. An http or https URL with a host, no username, password, query, or fragment. SAG removes trailing slashes
SAG_SECRETa well-known development valueMaster secret. 48 random bytes, unique to this issuer. Protects sessions, transactions and codes 1
SAG_SECRET_PREVIOUS-The secret being retired, so a rotation does not sign everybody out. See Operations
SAG_DEVtrue for localhost, .localhost, .local and .linux.test issuersForces development mode on or off
LOG_LEVELdebug in development, info otherwisedebug, info, warn, error, silent

1 Worth sealing rather than setting in plain text: see Sealed environment variables.

Signing​

VariableDefaultMeaning
SIGNING_BACKENDlocallocal, cloudflare-hsm, aws-kms
SIGNING_ALGES256The 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_SECRETHSMWith cloudflare-hsm. See Deployment
SIGNING_PUBLIC_JWKS_EXTRA[]Extra public keys to publish with the local or AWS KMS signer, for a migration in progress. The Cloudflare HSM publishes the keys it holds instead
REQUIRE_POST_QUANTUM_SIGNINGfalseRefuse to start unless a post-quantum algorithm is configured. The older SIGNING_REQUIRE_POST_QUANTUM name still works

There is one active private signing key per algorithm. SIGNING_ADDITIONAL_ALGS supports an algorithm migration, not a two-key overlap of the same algorithm. See Operations before rotating a signing key.

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.

VariableDefaultMeaning
PEER_JWKS_URLS-The full JWKS URL of every other instance of this issuer, each named by its own per-instance hostname rather than the issuer hostname. Empty means this feature is off and /jwks.json is exactly this instance's own keys
PEER_JWKS_CACHE_TTL300Seconds before a healthy peer is refetched
PEER_JWKS_STALE_TTLTwice SESSION_MAX_LIFETIMESeconds a peer's last known keys are still served, and still counted in /jwks.json, after it stops answering
PEER_JWKS_RETRY_AFTER30Seconds to leave a peer alone after a failed fetch, so an unreachable one costs one timeout per interval rather than one per request. 0 retries on every request. Also how long a /jwks.json that is missing a peer's keys may be cached for
PEER_JWKS_TIMEOUT_MS4000
PEER_JWKS_MAX_BYTES65536A peer's response over this size is refused rather than parsed
PEER_JWKS_CACHE_BACKENDmemorymemory, cf-kv, dynamodb. memory does not survive an isolate or container restart, so a peered deployment should set one of the other two and is warned in the log if it does not - see Multi-region
REQUIRE_PEER_JWKS_CACHEfalseRefuse to start unless peers are configured and PEER_JWKS_CACHE_BACKEND is durable, so a template or a Terraform refactor cannot drop either silently
PEER_JWKS_CACHE_KV_BINDINGSAG_PEER_JWKSWith cf-kv
PEER_JWKS_CACHE_TABLE, PEER_JWKS_CACHE_REGION-With dynamodb

Sessions​

VariableDefaultMeaning
SESSION_SCOPEsharedshared for one session across every relying party, rp for one each
SESSION_COOKIE_NAMEsag_session in development, __Host-sag_session otherwiseProduction prefixes a custom name too, and uses Secure; Path=/ as the prefix requires
SESSION_TTL43200 (12 hours)Idle timeout
SESSION_MAX_LIFETIME604800 (7 days)Absolute lifetime, regardless of activity
PROMPT_CONSENT_MODEcontinuecontinue shows "continue as ..." for prompt=consent and an omitted prompt; off ignores consent requests
LOGOUT_CONFIRMautoauto asks when the session is shared, always, never

prompt=none follows SESSION_SCOPE with no separate setting. Under shared a session can answer silently for a relying party that never signed the person in itself; under rp it cannot, because the cookie a relying party's request reads is its own. There is no fallback from one to the other in either direction - see ADR 0004.

Tokens and codes​

VariableDefaultMeaning
CODE_TTL60Authorisation code lifetime, in seconds
TRANSACTION_TTL900How long an in-flight sign-in may take
ID_TOKEN_TTL300
ACCESS_TOKEN_TTL600The access token is only accepted by SAG's own /userinfo
CLOCK_SKEW60Tolerance when checking times
SUBJECT_TYPEpublicpublic 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. Values shorter than 16 characters warn but remain unchanged. Rotation warning: a new salt orphans every account at every relying party 1
SANITISE_PLUS_EMAILStrueTreat jamie+shop@example.com as jamie@example.com for identity: one mailbox, one person. Overridable per relying party with CLIENT_<SLUG>_SANITISE_PLUS_EMAILS

1 Worth sealing rather than setting in plain text: see Sealed environment variables.

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.

Cross-origin requests (CORS)​

VariableDefaultMeaning
CORS_ENABLEDtrueWhether /token and /userinfo carry CORS headers at all
CORS_ALLOWED_ORIGINS*Comma or space separated browser origins to narrow to, in addition to every static client's registered redirect URIs. Unset means every origin

Neither endpoint relies on the session cookie - a /token request is bound to its authorization code by PKCE, and /userinfo is bound to its caller by the bearer access token - so there is nothing here for a third-party origin to ride on. Reading either response requires a code the caller cannot obtain without completing the flow, or a bearer token it already holds. The default is therefore every origin, the same as the discovery documents already allow: a browser-based relying party is the ordinary caller of these two routes, and refusing by default produced a CORS error to debug rather than a threat averted.

Set CORS_ALLOWED_ORIGINS to narrow that to a named list. Every origin among the statically configured clients' own CLIENT_<SLUG>_REDIRECT_URIS (https, or http in development) stays trusted alongside it, so narrowing to one partner's origin does not lock out the clients this deployment was configured with. A client that exists only in a client store or as a CIMD document is not known at start-up, so once you narrow, its origin has to be named here too. Each entry must be exactly an origin - scheme, host and port, no path - and https outside development; * is accepted, and is the default said out loud.

Set CORS_ENABLED=false to turn CORS off entirely. That is the only way to say no: an empty CORS_ALLOWED_ORIGINS means the default, not nothing. With it off, a relying party can still redeem a code or read a token from its own backend, just not from JavaScript running on a page.

Every other route, including the hosted sign-in pages, never carries a CORS header at all: they are reached by navigation and depend on the session cookie, which is a browser-enforced, same-origin thing already.

The state store​

See State and limits for what it is for and which backend to pick.

VariableDefaultMeaning
STATE_STORE_BACKENDnonenone, memory, cf-durable-object, dynamodb
STATE_STORE_DO_BINDINGSAG_STATEDurable Object namespace binding
STATE_STORE_TABLE, STATE_STORE_REGION-With dynamodb
STATE_STORE_MAX_ENTRIES10000Cap on the in-memory backend. A full store refuses a code or client assertion claim rather than forgetting one
REQUIRE_STATE_STOREfalseRefuse 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.

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

Sealed environment variables​

Any environment variable's value can be pasted as a reference into an AWS secret store, instead of in plain text:

MarkerResolves to
aws:kms:<ciphertext>The base64 output of aws kms encrypt
aws:secretsmanager:<secret id>A Secrets Manager secret, by name or ARN
aws:ssm:<name>An SSM parameter (WithDecryption is always requested, so a SecureString is unwrapped)

Resolution happens once per warm instance, before configuration is parsed, using the same signed-HTTPS call as everything else under src/keys/, so it works identically on Lambda, ECS, EC2 or a plain Node process: whichever one hands SAG ambient AWS credentials (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN) and AWS_REGION. A deployment with nothing sealed makes no AWS call and needs no AWS credentials at all. A failure to resolve is a startup error, never a silent fall-back to the reference itself.

AWS_ENDPOINT_URL_KMS, AWS_ENDPOINT_URL_SECRETS_MANAGER and AWS_ENDPOINT_URL_SSM (or the global AWS_ENDPOINT_URL) apply here too, for a local stack.

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​

VariableDefaultMeaning
OTP_ENABLEDtrue
OTP_CODE_LENGTH9Minimum 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_ALPHABETalphanumericalphanumeric (30 symbols, no confusable characters) or numeric
OTP_TTL600
OTP_MAX_ATTEMPTS5Per transaction, and see the honesty note in Limitations
OTP_MAX_RESENDS3Per transaction
OTP_SEND_WINDOW600The rate limit window, in seconds. Needs a state store. OTP_SEND_MIN_INTERVAL is the older name
OTP_SEND_BURST2Codes to one address within a window
OTP_SEND_DAILY_LIMIT5Codes 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_ADDRESSsilentsilent shows the code screen for an address no route can serve; explain says so

Sending the mail​

VariableMeaning
EMAIL_PROVIDERconsole, ses, notify, mailchannels, cloudflare, smtp
EMAIL_FROMSign in <no-reply@id.example.com>
EMAIL_REPLY_TO, EMAIL_OTP_SUBJECTOptional
SES_REGION, SES_CONFIGURATION_SETAWS SES
NOTIFY_API_KEY, NOTIFY_TEMPLATE_ID, NOTIFY_BASE_URLGOV.UK Notify, which owns the wording of the message
MAILCHANNELS_ENDPOINT, MAILCHANNELS_API_KEYMailChannels, for Workers
CLOUDFLARE_EMAIL_BINDING, CLOUDFLARE_EMAIL_DESTINATIONCloudflare Email Routing
SMTP_URLsmtps://user:pass@host:465

console only prints codes to the log, so it is refused outside development.

Relying party store​

VariableDefaultMeaning
CLIENTS_STORE_BACKENDnonenone, file, cf-kv, s3
CLIENTS_STORE_DIR<SAG_DATA_DIR>/clientsWith file: the directory of <client id>.json records
CLIENTS_STORE_KV_BINDINGSAG_CLIENTSWith cf-kv
CLIENTS_STORE_S3_BUCKET, CLIENTS_STORE_S3_REGION-With s3
CLIENTS_STORE_PREFIXclients/, or empty with fileKey prefix within the store
CLIENTS_STORE_CACHE_TTL60Seconds 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_ENABLEDtrueWhether store-held clients are accepted at all
CLIENTS_REDIRECT_URI_SCHEMES*Comma or space separated schemes accepted for authorisation and post-logout redirects, without the colon, or * for any. Exact registered URI matching still applies
CLIENTS_CIMD_ENABLEDDevelopment modeClient ID Metadata Documents. Production deployments must enable it explicitly.
CLIENTS_CIMD_ALLOWED_DOMAINS-Optional additional allow-list. Empty accepts any public host; every listed domain may declare its own redirect URIs.
CLIENTS_CIMD_ALLOW_SUBDOMAINStrue
CLIENTS_CIMD_CACHE_TTL300
CLIENTS_CIMD_MAX_BYTES32768Size cap on a fetched document

See Branding.

VariableDefaultMeaning
UI_TITLESign in
UI_ORG_NAME, UI_LOGO_URL-An operator logo at the bottom of the footer; the organisation name is its alternative text
UI_BRAND_NAME, UI_PRODUCT_NAME, UI_BRAND_URLRESOAuth, Smart Access Gateway, https://resoauth.dev
UI_WHITELABELfalseDrops 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_LOCALEen-GBThe 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.

VariableDefaultMeaning
PROFILE_CLAIMSname given_name family_name preferred_username picture localeWhich OpenID Connect profile claims may be relayed from an upstream. Anything not named is dropped
PROFILE_PICTUREtrueSeparate 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_EMAILoffinfer 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_FALLBACKoffinitials draws an initials avatar as a data: URI when there is no upstream picture. Asks no avatar service anything
PROFILE_SHOW_ON_SCREENtrueWhether SAG's own screens show the name and picture they hold

Routing an address to a provider​

See Upstreams.

VariableDefaultMeaning
SIGNIN_PROVIDER_HINTselectWhat 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_URLhttps://cloudflare-dns.com/dns-queryDNS-over-HTTPS, used only where the platform has no resolver. Lambda has none; the Node and Cloudflare adapters supply the platform's own, and on Node no query leaves the deployment. Setting this on Workers overrides the platform resolver, which is rarely what you want - a Worker's fetch to a DNS-over-HTTPS endpoint does not come back
DNS_BINDINGSAG_DNSWhere an adapter puts a platform resolver
DNS_TIMEOUT_MS1500A lookup that does not answer is simply not an answer
DNS_CACHE_TTL3600Seconds an answer is cached, per instance. A domain that matched nothing is cached for five minutes

Authentication context​

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