Added

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

✨ 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 the Host header only (same behavior as nextjs-auth version 4.x and earlier)
  • true: the host is read from the first value of the X-Forwarded-Host header, falling back to the Host header when it is missing or empty
  • The host is used to resolve tenant subdomains in login(), callback(), and logout(), to validate return URLs in callback(), and to resolve a relative resolveRedirectUrl path in appRouter.callback()
  • Only enable this when the reverse proxy sets or overwrites the X-Forwarded-Host header

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 the X-Forwarded-Host header when trustProxyHeaders is enabled)
  • 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)

🔧 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 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 (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 }).

🔧 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 the X-Forwarded-Host header when trustProxyHeaders is enabled)
  • The result always contains a ready-made redirect response with the Login State Cookie already cleared: to the resolved URL when type is 'completed', or to the Login Endpoint when type is 'redirect_required'
  • The App Router result type is now AppRouterCallbackResult, and the Pages Router result type is now PagesRouterCallbackResult. The Pages Router callback() is otherwise unchanged.

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

  • createMiddlewareAuth() saves the session on every request it authenticates with the SESSION strategy (rolling sessions), so those responses always include the headers
  • App Router: session.saveToResponse() and session.destroyToResponse() set the headers on the returned response. Pages Router: session.save() and session.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-store on 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-Control value set on a response that also saves or destroys the session is now replaced with no-store. For routes protected by createMiddlewareAuth(), a custom value set in a Node.js runtime route handler is replaced by the middleware's no-store value. In an Edge runtime route handler, or on Next.js 14, both values are sent. The response is still not cached, since no-store applies 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.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

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

🗑️ appRouter.createCallbackResponse()

Removed. Its logic is now part of appRouter.callback(), which returns the redirect response directly.

🗑️ CallbackResult Type

Replaced by the router-specific AppRouterCallbackResult and PagesRouterCallbackResult types.