App-Level Auth Requests for Next.js Auth SDK
📣 Wristband Next.js Auth SDK v7.0.0 Released 🎉
Release v7.0.0
Migration Guide
v7: https://github.com/wristband-dev/nextjs-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
✨ Trusted Proxy Host Support
Adds the trustProxyHeaders config option (defaults to false) for applications running behind a reverse proxy, where the Host header the app receives is an internal address instead of the public domain.
Behavior:
false(default): the host is read from theHostheader only (same behavior asnextjs-authversion 4.x and earlier)true: the host is read from the first value of theX-Forwarded-Hostheader, falling back to theHostheader when it is missing or empty- The host is used to resolve tenant subdomains in
login(),callback(), andlogout(), to validate return URLs incallback(), and to resolve a relativeresolveRedirectUrlpath inappRouter.callback() - Only enable this when the reverse proxy sets or overwrites the
X-Forwarded-Hostheader
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 (or theX-Forwarded-Hostheader whentrustProxyHeadersis enabled) - 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)
🔧 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 v4.x cannot be decrypted by v5.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 }).
🔧 Simpler App Router Callback
appRouter.callback() now builds the callback's redirect response itself, so the Callback Endpoint no longer needs a separate createCallbackResponse() call.
appRouter.callback()takes a second argument,resolveRedirectUrl: a function that receives the request and the callback data, and returns where to send the user after a successful login. It can return the URL directly or as a Promise, and it only runs when the callback completes.- The URL can be absolute or a relative path (e.g.
/home), which is resolved against the host of the incoming request (or theX-Forwarded-Hostheader whentrustProxyHeadersis enabled) - The result always contains a ready-made redirect
responsewith the Login State Cookie already cleared: to the resolved URL whentypeis'completed', or to the Login Endpoint whentypeis'redirect_required' - The App Router result type is now
AppRouterCallbackResult, and the Pages Router result type is nowPagesRouterCallbackResult. The Pages Routercallback()is otherwise unchanged.
🔧 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.
createMiddlewareAuth()saves the session on every request it authenticates with theSESSIONstrategy (rolling sessions), so those responses always include the headers- App Router:
session.saveToResponse()andsession.destroyToResponse()set the headers on the returned response. Pages Router:session.save()andsession.destroy()set the headers on the response object. - Server Actions: the SDK does not set the headers, since there is no response object. Next.js already sends
Cache-Control: no-storeon every Server Action response. - 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. For routes protected bycreateMiddlewareAuth(), a custom value set in a Node.js runtime route handler is replaced by the middleware'sno-storevalue. In an Edge runtime route handler, or on Next.js 14, both values are sent. The response is still not cached, sinceno-storeapplies whenever it is present.
Session cookie encoding:
- Encrypted session cookie values are now base64url-encoded, matching the Login State Cookie
- Backward compatible: session cookies created by v4.x are still decrypted, so existing users stay logged in after the upgrade
- Rollback impact: v4.x cannot read session cookies created by v5.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
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
🗑️ appRouter.createCallbackResponse()
appRouter.createCallbackResponse()Removed. Its logic is now part of appRouter.callback(), which returns the redirect response directly.
🗑️ CallbackResult Type
CallbackResult TypeReplaced by the router-specific AppRouterCallbackResult and PagesRouterCallbackResult types.