Added

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_name or tenant_custom_domain query params, defaults, or a bookmarked Tenant-Level Login URL): no change, the standard tenant-level flow always applies
  • logout(): with no resolvable tenant and no redirectUrl in LogoutConfig, redirects back to the application's own Login Endpoint to start a new app-level flow (loginUrl with the {tenant_name} subdomain removed if it contains the placeholder, otherwise loginUrl as is)
  • customApplicationLoginPageUrl has no effect on login() or logout() when enabled
  • Login State Cookie: when enabled and tenant subdomains are in use (i.e., loginUrl contains 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, and callback() 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 the tenant_custom_domain query param (highest priority)
  • callback(): validates the tenant_custom_domain query param sent by Wristband. A skipped domain is also omitted from the login redirect URL and from the returned callback data.
  • logout(): validates the tenant_custom_domain query param, which is only used when no higher-priority LogoutConfig value 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 https to http
  • The hostname must be either the root domain (when loginUrl contains the {tenant_name} placeholder) or the request's host
  • When application-level authorization requests are enabled and loginUrl contains the {tenant_name} placeholder, a return URL on the bare root domain is rewritten to the tenant's subdomain (e.g. myapp.com to acme.myapp.com)
  • The request's host and protocol follow the app's Express trust proxy setting, so X-Forwarded-Host and X-Forwarded-Proto are used only when the proxy is trusted. A port in X-Forwarded-Host is 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 from clientSecret when loginStateSecret is 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 customState value that is not JSON-serializable throws a TypeError
  • 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, and callback() returns redirect_required with the invalid_login_state reason.
  • 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 (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.customState or userinfo.customClaims must be narrowed or cast before use (e.g. callbackData.customState?.userId as string)
  • A customState value declared with an interface is no longer assignable and causes a type error. Declare it with a type alias instead, or pass a spread copy (e.g. customState: { ...myState }).

🔧 Session Management Upgraded to @wristband/typescript-session v1.0.0

The 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 SESSION strategy (rolling sessions), so those responses always include the headers
  • session.save() and session.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-Control value set on a response that also saves or destroys the session is now replaced with no-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.returnUrl first, then the return_url query 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 proxy enabled: the host comes from X-Forwarded-Host when the proxy is trusted
  • trust proxy disabled (the Express default): no change, the Host header 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

The {tenant_domain} placeholder is no longer supported. {tenant_name} is now the only supported tenant placeholder in loginUrl and redirectUri.

🗑️ parseTenantFromRootDomain Config

The 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 in loginUrl and redirectUri, as the leftmost part of the hostname