Skip to main content
Version: Next (unreleased)

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. No trailing slash, no query
SAG_SECRETa well-known development valueMaster 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_DEVtrue for localhost, .localhost, .local and .linux.test issuersForces development mode on or off
LOG_LEVELdebug in development, info otherwisedebug, info, warn, error, silent

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, for a migration in progress
REQUIRE_POST_QUANTUM_SIGNINGfalseRefuse 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.

VariableDefaultMeaning
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_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_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 - see Multi-region
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
SESSION_TTL43200 (12 hours)Idle timeout
SESSION_MAX_LIFETIME604800 (7 days)Absolute lifetime, regardless of activity
PROMPT_NONE_SHARED_SESSIONtrueWhether prompt=none may be answered from the shared session when sessions are per relying party
PROMPT_CONSENT_MODEcontinuecontinue shows "continue as ..."; off ignores prompt=consent
LOGOUT_CONFIRMautoauto asks when the session is shared, always, never

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. Rotation warning: a new salt orphans every account at every relying party
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

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.

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

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_CIMD_ENABLEDtrueClient ID Metadata Documents
CLIENTS_CIMD_ALLOWED_DOMAINS-Empty means any origin
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-The operator's own identity, shown in the header
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. Workers and Lambda have none; the Node adapter supplies the host's own, and then no query leaves the deployment
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.