App-Level Auth Requests for Express Auth SDK
📣 Wristband Express Auth SDK v7.0.0 Released 🎉
Release v7.0.0
Migration Guide
v7: https://github.com/wristband-dev/express-auth/blob/main/migration/v7/README.md
New Features
✨ Application-Level Authorization Requests
Adds support for application-level authorization requests. Third-party platforms and SDKs that require a single, static Authorization Endpoint URL (e.g. Supabase) can now use one fixed URL in addition to per-tenant URLs.
Enabling:
- Enabled by default for new applications created in the Wristband Dashboard. It can be turned on or off from the Application's Settings page.
- SDK config:
applicationAuthorizationRequestsEnabled(auto-configured by default)
Behavior:
login(): when no tenant custom domain or tenant subdomain can be resolved, redirects straight to the Wristband Authorize Endpoint on the application's vanity domain instead of the Application-Level Login (Tenant Discovery) Page- Wristband handles tenant discovery within that same authorization flow, and the return URL is preserved in the Login State Cookie
- Resolvable tenant (subdomain,
tenant_nameortenant_custom_domainquery params, defaults, or a bookmarked Tenant-Level Login URL): no change, the standard tenant-level flow always applies logout(): with no resolvable tenant and noredirectUrlinLogoutConfig, redirects back to the application's own Login Endpoint to start a new app-level flow (loginUrlwith the{tenant_name}subdomain removed if it contains the placeholder, otherwiseloginUrlas is)customApplicationLoginPageUrlhas no effect onlogin()orlogout()when enabled- Login State Cookie: when enabled and tenant subdomains are in use (i.e.,
loginUrlcontains the{tenant_name}placeholder), it is always set on the root domain (e.g..yourapp.io) in both the app-level and tenant-level login flows, andcallback()clears it with the same domain. Otherwise it stays host-only.
✨ Tenant Custom Domain Validation
The tenant_custom_domain query parameter is now validated against Wristband's Tenant Custom Domain Validation API before it is used. An unverified value is ignored, and the SDK falls through to the next entry in its domain precedence order instead of using it to build a redirect URL.
Behavior:
login(): validates thetenant_custom_domainquery param (highest priority)callback(): validates thetenant_custom_domainquery param sent by Wristband. A skipped domain is also omitted from the login redirect URL and from the returned callback data.logout(): validates thetenant_custom_domainquery param, which is only used when no higher-priorityLogoutConfigvalue is set
Improvements
🔧 Shared Retry Logic for All Wristband API Calls
Every external API call to Wristband in the SDK now goes through one shared retry mechanism: up to 3 attempts, retrying on 5xx responses and network errors but not on 4xx. The delay between attempts doubles after each failure (API_RETRY_DELAY_MULTIPLIER, default 2).
🔧 Return URL Validation
callback() now validates the return URL before including it in the returned callback data. A return URL that fails validation is left out of the callback data, and the flow continues without it.
- Relative paths (e.g.
/settings) resolve against the current request's host - The protocol must match the request's protocol, which blocks downgrades like
httpstohttp - The hostname must be either the root domain (when
loginUrlcontains the{tenant_name}placeholder) or the request's host - When application-level authorization requests are enabled and
loginUrlcontains the{tenant_name}placeholder, a return URL on the bare root domain is rewritten to the tenant's subdomain (e.g.myapp.comtoacme.myapp.com) - The request's host and protocol follow the app's Express
trust proxysetting, soX-Forwarded-HostandX-Forwarded-Protoare used only when the proxy is trusted. A port inX-Forwarded-Hostis preserved.
🔧 In-House Login State Cookie Encryption
The iron-webcrypto and uncrypto dependencies have been removed. The Login State Cookie is now encrypted with the same in-house crypto implementation used by the @wristband/typescript-session SDK, with no third-party crypto dependencies.
- AES-256-GCM encryption, with the key derived using HKDF-SHA256 from
loginStateSecret, or fromclientSecretwhenloginStateSecretis not set - Encrypted cookie values are base64url-encoded
- The Login State Cookie's 1-hour lifetime is now also enforced when decrypting, with a 60-second clock skew tolerance
- A
customStatevalue that is not JSON-serializable throws aTypeError callback(): a Login State Cookie that cannot be decrypted (tampered, expired, encrypted with a different secret, or created by a previous SDK version) no longer throws. The cookie is cleared, andcallback()returnsredirect_requiredwith theinvalid_login_statereason.- Upgrade impact: Login State Cookies created by v6.x cannot be decrypted by v7.0.0, so a user who is mid-login during the upgrade is redirected to the Login Endpoint once. Because Wristband already has an authenticated session for that user, the new login flow completes without asking them to sign in again.
🔧 Stricter Types for customState and customClaims
customState and customClaimscustomState (in LoginConfig and CallbackData) and customClaims (in UserInfo) are now typed as Record<string, unknown> instead of allowing any values. Runtime behavior is unchanged, and JavaScript apps are not affected.
- Values read from
callbackData.customStateoruserinfo.customClaimsmust be narrowed or cast before use (e.g.callbackData.customState?.userId as string) - A
customStatevalue declared with aninterfaceis no longer assignable and causes a type error. Declare it with atypealias instead, or pass a spread copy (e.g.customState: { ...myState }).
🔧 Session Management Upgraded to @wristband/typescript-session v1.0.0
@wristband/typescript-session v1.0.0The bundled session SDK has been upgraded from v0.2.0 to v1.0.0.
No-cache headers:
Any response that writes or deletes a session cookie now automatically includes Cache-Control: no-store and Pragma: no-cache. This prevents browsers and shared caches (such as CDNs and proxies) from storing a response that carries a user's session cookie and serving it to someone else. Previously, apps were solely responsible for setting these headers.
- The auth middleware saves the session on every request it authenticates with the
SESSIONstrategy (rolling sessions), so those responses always include the headers session.save()andsession.destroy()set the headers on any route that calls them- Routes that do not save or destroy the session are unchanged and do not get the headers automatically
- Upgrade impact: a custom
Cache-Controlvalue set on a response that also saves or destroys the session is now replaced withno-store
Session cookie encoding:
- Encrypted session cookie values are now base64url-encoded, matching the Login State Cookie
- Backward compatible: session cookies created by v6.x are still decrypted, so existing users stay logged in after the upgrade
- Rollback impact: v6.x cannot read session cookies created by v7.0.0, so rolling back after the upgrade logs out users with a new cookie once
Bug Fixes
🐛 Return URL Preserved Through the Application-Level Login Page
When application-level authorization requests are disabled and no tenant can be resolved, login() now appends the return URL as the state query parameter on the Application-Level Login (Tenant Discovery) Page URL. Previously, the return URL was dropped on this path.
- Applies to both the Wristband-hosted page and
customApplicationLoginPageUrl - The return URL is resolved the same way as in the tenant-level flow:
LoginConfig.returnUrlfirst, then thereturn_urlquery param - The value is URL-encoded. Return URLs longer than 512 characters are dropped, and the flow continues without them
logout()behavior is unchanged
🐛 Tenant Subdomain Parsing Behind a Reverse Proxy
Tenant subdomain parsing now resolves the request host through Express (req.hostname) instead of reading the raw Host header, so it follows the app's trust proxy setting. Previously, behind a reverse proxy that rewrites the Host header, the tenant subdomain could not be found.
trust proxyenabled: the host comes fromX-Forwarded-Hostwhen the proxy is trustedtrust proxydisabled (the Express default): no change, theHostheader is used
Removed
🗑️ Node.js 20 Support
The minimum supported Node.js version is now 22.0.0 (previously 20.0.0), and the minimum npm version is now 10.5.1 (previously 9.6.0). Node.js 20 reached end of life on April 30, 2026.
- The CI test matrix now runs on Node.js 22, 24, and 26
- The npm publish workflow now builds and publishes on Node.js 22
🗑️ {tenant_domain} Placeholder
{tenant_domain} PlaceholderThe {tenant_domain} placeholder is no longer supported. {tenant_name} is now the only supported tenant placeholder in loginUrl and redirectUri.
🗑️ parseTenantFromRootDomain Config
parseTenantFromRootDomain ConfigThe parseTenantFromRootDomain config has been removed from AuthConfig. Tenant subdomain parsing is now enabled whenever loginUrl contains the {tenant_name} placeholder, and the SDK determines the root domain from loginUrl by removing the {tenant_name} subdomain (e.g. https://{tenant_name}.yourapp.io/auth/login gives a root domain of yourapp.io).
- The
{tenant_name}placeholder must appear exactly once inloginUrlandredirectUri, as the leftmost part of the hostname