[GH-ISSUE #7957] SAML Redirect Binding: Signature verification fails when RelayState is present #19584

Open
opened 2026-04-15 18:51:12 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @mifopen on GitHub (Feb 13, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7957

Originally assigned to: @Paola3stefania on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

// Register SAML provider with signed AuthnRequests
  await fetch("/api/auth/sso/register", {
    method: "POST",
    body: JSON.stringify({
      providerId: "test",
      issuer: "https://sp.example.com",
      domain: "example.com",
      samlConfig: {
        entryPoint: "https://idp.example.com/sso",
        cert: "<IdP cert>",
        callbackUrl: "https://sp.example.com/acs",
        idpMetadata: { metadata: "<IdP metadata with WantAuthnRequestsSigned=true>" },
        spMetadata: {
          metadata: "<SP metadata with AuthnRequestsSigned=true>",
          privateKey: "<SP private key>"
        },
        authnRequestsSigned: true,
        wantAssertionsSigned: true,
        identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
      }
    })
  });

  // Initiate sign-in
  const response = await fetch("/api/auth/sign-in/sso", {
    method: "POST",
    body: JSON.stringify({
      providerId: "test",
      cal
lbackURL: "https://sp.example.com/",
      providerType: "saml"
    })
  });

  // Navigate to response.url
  // Result: IdP rejects with signature verification error

Current vs. Expected behavior

The @better-auth/sso package generates invalid SAML redirect binding signatures when RelayState is present. The signature is computed before RelayState is appended to the URL, causing IdP signature verification to fail.

In node_modules/@better-auth/sso/src/routes/sso.ts:

Lines 1327-1334: sp.createLoginRequest(idp, "redirect") generates a signed URL WITHOUT RelayState:

  const loginRequest = sp.createLoginRequest(
      idp,
      "redirect",
  ) as BindingContext & {
      entityEndpoint: string;
      type: string;
      id: string;
  };

This calls samlify's binding-redirect.ts which computes the signature over:
SAMLRequest=&SigAlg=&Signature=

Lines 1341-1365: RelayState is generated and appended AFTER signing:
const { state: relayState } = await generateRelayState(ctx, undefined, false);

  return ctx.json({
      url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
      redirect: true,
  });

This produces a final URL like:
?SAMLRequest=...&SigAlg=...&Signature=...&RelayState=...

Expected Behavior

Per https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf, when verifying the signature, the IdP reconstructs:
SAMLRequest=&RelayState=&SigAlg=

But the signature was computed over:
SAMLRequest=&SigAlg=

This mismatch causes signature verification to fail on the IdP side.

Expected Fix

RelayState must be included before signature computation. In sso.ts, the signInSSO handler should:

  1. Generate RelayState before calling sp.createLoginRequest()
  2. Pass RelayState to samlify via sp.entitySetting.relayState
  3. Let samlify include it in signature computation

Pseudo-code:

  // Generate RelayState first
  const { state: relayState } = await generateRelayState(ctx, undefined, false);

  // Set it in SP settings so samlify includes it in signing
  const sp = saml.ServiceProvider({
      metadata: metadata,
      allowCreate: true,
      privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
      privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
      relayState: relayState  // Add this
  });

  const loginRequest = sp.createLoginRequest(idp, "redirect");

  // Return URL without manually appending RelayState
  return ctx.json({
      url: loginRequest.context,  // Already includes RelayState with correct signature
      redirect: true,
  });

What version of Better Auth are you using?

1.5.0-beta.11

System info

-

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)


Additional context

  • Samlify library correctly implements signature generation when relayState is provided via entitySetting (see node_modules/samlify/src/binding-redirect.ts lines 46-79)
    • The bug is in better-auth's orchestration, not samlify itself

PS. Sorry for the https://letmegptthatforyou.com vibe of the issue! It's just what claude spit out after we tried to troubleshoot our connection to some enterprise idP

Originally created by @mifopen on GitHub (Feb 13, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7957 Originally assigned to: @Paola3stefania on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce ``` // Register SAML provider with signed AuthnRequests await fetch("/api/auth/sso/register", { method: "POST", body: JSON.stringify({ providerId: "test", issuer: "https://sp.example.com", domain: "example.com", samlConfig: { entryPoint: "https://idp.example.com/sso", cert: "<IdP cert>", callbackUrl: "https://sp.example.com/acs", idpMetadata: { metadata: "<IdP metadata with WantAuthnRequestsSigned=true>" }, spMetadata: { metadata: "<SP metadata with AuthnRequestsSigned=true>", privateKey: "<SP private key>" }, authnRequestsSigned: true, wantAssertionsSigned: true, identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" } }) }); // Initiate sign-in const response = await fetch("/api/auth/sign-in/sso", { method: "POST", body: JSON.stringify({ providerId: "test", cal lbackURL: "https://sp.example.com/", providerType: "saml" }) }); // Navigate to response.url // Result: IdP rejects with signature verification error ``` ### Current vs. Expected behavior The @better-auth/sso package generates invalid SAML redirect binding signatures when RelayState is present. The signature is computed before RelayState is appended to the URL, causing IdP signature verification to fail. In node_modules/@better-auth/sso/src/routes/sso.ts: Lines 1327-1334: sp.createLoginRequest(idp, "redirect") generates a signed URL WITHOUT RelayState: ``` const loginRequest = sp.createLoginRequest( idp, "redirect", ) as BindingContext & { entityEndpoint: string; type: string; id: string; }; ``` This calls samlify's binding-redirect.ts which computes the signature over: SAMLRequest=<encoded>&SigAlg=<algo>&Signature=<sig> Lines 1341-1365: RelayState is generated and appended AFTER signing: const { state: relayState } = await generateRelayState(ctx, undefined, false); ``` return ctx.json({ url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`, redirect: true, }); ``` This produces a final URL like: ?SAMLRequest=...&SigAlg=...&Signature=...&RelayState=... Expected Behavior Per https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf, when verifying the signature, the IdP reconstructs: SAMLRequest=<encoded>&RelayState=<state>&SigAlg=<algo> But the signature was computed over: SAMLRequest=<encoded>&SigAlg=<algo> This mismatch causes signature verification to fail on the IdP side. Expected Fix RelayState must be included before signature computation. In sso.ts, the signInSSO handler should: 1. Generate RelayState before calling sp.createLoginRequest() 2. Pass RelayState to samlify via sp.entitySetting.relayState 3. Let samlify include it in signature computation Pseudo-code: ``` // Generate RelayState first const { state: relayState } = await generateRelayState(ctx, undefined, false); // Set it in SP settings so samlify includes it in signing const sp = saml.ServiceProvider({ metadata: metadata, allowCreate: true, privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey, privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass, relayState: relayState // Add this }); const loginRequest = sp.createLoginRequest(idp, "redirect"); // Return URL without manually appending RelayState return ctx.json({ url: loginRequest.context, // Already includes RelayState with correct signature redirect: true, }); ``` ### What version of Better Auth are you using? 1.5.0-beta.11 ### System info ```bash - ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript ``` ### Additional context - Samlify library correctly implements signature generation when relayState is provided via entitySetting (see node_modules/samlify/src/binding-redirect.ts lines 46-79) - The bug is in better-auth's orchestration, not samlify itself PS. Sorry for the https://letmegptthatforyou.com vibe of the issue! It's just what claude spit out after we tried to troubleshoot our connection to some enterprise idP
GiteaMirror added the identitybug labels 2026-04-15 18:51:13 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Feb 13, 2026):

You're correct—this is a real bug in @better-auth/sso. The current implementation generates and signs the SAML Redirect URL before RelayState is available, then appends RelayState afterward, so the signature doesn't cover RelayState. This causes signature verification to fail at the IdP, since the SAML spec requires RelayState to be included in the signature if present (spec reference).

The fix is to generate RelayState before calling sp.createLoginRequest, and pass it to the samlify ServiceProvider so it's included in the signature computation. The samlify library supports this if you provide relayState up front. The relevant code is in signInSSO in packages/sso/src/routes/sso.ts—currently, RelayState is generated and appended after signing, which is the root cause of the issue.

Your proposed pseudo-code is correct and matches both the SAML spec and samlify's intended usage. There are no known side effects to this change, but you should ensure that any state persistence or validation logic that depends on RelayState is updated to expect it to be generated earlier in the flow.

References:

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3896772336 --> @dosubot[bot] commented on GitHub (Feb 13, 2026): <!-- Answer --> You're correct—this is a real bug in @better-auth/sso. The current implementation generates and signs the SAML Redirect URL before RelayState is available, then appends RelayState afterward, so the signature doesn't cover RelayState. This causes signature verification to fail at the IdP, since the SAML spec requires RelayState to be included in the signature if present ([spec reference](https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf)). The fix is to generate RelayState before calling `sp.createLoginRequest`, and pass it to the samlify ServiceProvider so it's included in the signature computation. The samlify library supports this if you provide `relayState` up front. The relevant code is in `signInSSO` in `packages/sso/src/routes/sso.ts`—currently, RelayState is generated and appended after signing, which is the root cause of the issue. Your proposed pseudo-code is correct and matches both the SAML spec and samlify's intended usage. There are no known side effects to this change, but you should ensure that any state persistence or validation logic that depends on RelayState is updated to expect it to be generated earlier in the flow. References: - [signInSSO handler confirms bug and fix](https://github.com/better-auth/better-auth/blob/0669ed804d8e0ada004c5ba266a6a12be6557cfc/packages/sso/src/routes/sso.ts#L1257-L2274) - [RelayState generation logic](https://github.com/better-auth/better-auth/blob/0669ed804d8e0ada004c5ba266a6a12be6557cfc/packages/sso/src/saml-state.ts#L6-L78) <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/98a4e864-2015-4b0e-a841-82f4a53707fc?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/98a4e864-2015-4b0e-a841-82f4a53707fc?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/98a4e864-2015-4b0e-a841-82f4a53707fc?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/98a4e864-2015-4b0e-a841-82f4a53707fc?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/98a4e864-2015-4b0e-a841-82f4a53707fc?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/98a4e864-2015-4b0e-a841-82f4a53707fc?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/98a4e864-2015-4b0e-a841-82f4a53707fc?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27re%20correct%E2%80%94this%20is%20a%20real%20bug%20in%20%40better-auth/sso.%20The%20current%20implementation%20generates%20and%20signs%20the%20SAML%20Redirect%20URL%20before%20RelayState%20is%20available%2C%20then%20appends%20RelayState%20afterward%2C%20so%20the%20signature%20doesn%27t%20cover%20RelayState.%20This%20causes%20signature%20verification%20to%20fail%20at%20the%20IdP%2C%20since%20the%20SAML%20spec%20requires%20RelayState%20to%20be%20included%20in%20the%20signature%20if%20present%20%28%5Bspec%20reference%5D%28https%3A//docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf%29%29.%0A%0AThe%20fix%20is%20to%20generate%20RelayState%20before%20calling%20%60sp.createLoginRequest%60%2C%20and%20pass%20it%20to%20the%20samlify%20ServiceProvider%20so%20it%27s%20included%20in%20the%20signature%20computation.%20The%20samlify%20library%20supports%20this%20if%20you%20provide%20%60relayState%60%20up%20front.%20The%20relevant%20code%20is%20in%20%60signInSSO%60%20in%20%60packages/sso/src/routes/sso.ts%60%E2%80%94currently%2C%20RelayState%20is%20generated%20and%20appended%20after%20signing%2C%20which%20is%20the%20root%20cause%20of%20the%20issue.%0A%0AYour%20proposed%20pseudo-code%20is%20correct%20and%20matches%20both%20the%20SAML%20spec%20and%20samlify%27s%20intended%20usage.%20There%20are%20no%20known%20side%20effects%20to%20this%20change%2C%20but%20you%20should%20ensure%20that%20any%20state%20persistence%20or%20validation%20logic%20that%20depends%20on%20RelayState%20is%20updated%20to%20expect%20it%20to%20be%20generated%20earlier%20in%20the%20flow.%0A%0AReferences%3A%20%20%0A-%20%5BsignInSSO%20handler%20confirms%20bug%20and%20fix%5D%28https%3A//github.com/better-auth/better-auth/blob/0669ed804d8e0ada004c5ba266a6a12be6557cfc/packages/sso/src/routes/sso.ts%23L1257-L2274%29%0A-%20%5BRelayState%20generation%20logic%5D%28https%3A//github.com/better-auth/better-auth/blob/0669ed804d8e0ada004c5ba266a6a12be6557cfc/packages/sso/src/saml-state.ts%23L6-L78%29)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/7957)
Author
Owner

@kbzowski commented on GitHub (Apr 2, 2026):

Hit this in production with a Shibboleth IdP (university SSO, WantAuthnRequestsSigned="true"). The IdP rejects every AuthnRequest because the signature doesn't cover RelayState.

For anyone landing here - a minimal pnpm patch that fixes it:

diff --git a/dist/index.mjs b/dist/index.mjs
index 7cd34ab445ea8e501c2d319a6d74e924ba67e578..aed1c7fd6cf098ed9cf5e943613f6714135c29ad 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -2205,7 +2205,7 @@ const signInSSO = (options) => {
 			const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
 			if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
 			if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) ctx.context.logger.warn("authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed", { providerId: provider.providerId });
-			let metadata = parsedSamlConfig.spMetadata.metadata;
+			let metadata = parsedSamlConfig.spMetadata?.metadata;
 			if (!metadata) metadata = saml.SPMetadata({
 				entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
 				assertionConsumerService: [{
@@ -2244,9 +2244,10 @@ const signInSSO = (options) => {
 				encPrivateKey: idpData.encPrivateKey,
 				encPrivateKeyPass: idpData.encPrivateKeyPass
 			});
+			const { state: relayState } = await generateRelayState(ctx, void 0, false);
+			sp.entitySetting.relayState = relayState;
 			const loginRequest = sp.createLoginRequest(idp, "redirect");
 			if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
-			const { state: relayState } = await generateRelayState(ctx, void 0, false);
 			if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
 				const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
 				const record = {
@@ -2262,7 +2263,7 @@ const signInSSO = (options) => {
 				});
 			}
 			return ctx.json({
-				url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
+				url: loginRequest.context,
 				redirect: true
 			});
 		}

Moving generateRelayState before createLoginRequest and setting sp.entitySetting.relayState lets samlify include it in signature computation. Then loginRequest.context already contains the correctly signed RelayState param - no need to append it manually.

Also worth noting: line 2208 has parsedSamlConfig.spMetadata.metadata which crashes if spMetadata is undefined - should be spMetadata?.metadata.

<!-- gh-comment-id:4175090385 --> @kbzowski commented on GitHub (Apr 2, 2026): Hit this in production with a Shibboleth IdP (university SSO, `WantAuthnRequestsSigned="true"`). The IdP rejects every AuthnRequest because the signature doesn't cover RelayState. For anyone landing here - a minimal pnpm patch that fixes it: ```diff diff --git a/dist/index.mjs b/dist/index.mjs index 7cd34ab445ea8e501c2d319a6d74e924ba67e578..aed1c7fd6cf098ed9cf5e943613f6714135c29ad 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -2205,7 +2205,7 @@ const signInSSO = (options) => { const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig); if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" }); if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) ctx.context.logger.warn("authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed", { providerId: provider.providerId }); - let metadata = parsedSamlConfig.spMetadata.metadata; + let metadata = parsedSamlConfig.spMetadata?.metadata; if (!metadata) metadata = saml.SPMetadata({ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer, assertionConsumerService: [{ @@ -2244,9 +2244,10 @@ const signInSSO = (options) => { encPrivateKey: idpData.encPrivateKey, encPrivateKeyPass: idpData.encPrivateKeyPass }); + const { state: relayState } = await generateRelayState(ctx, void 0, false); + sp.entitySetting.relayState = relayState; const loginRequest = sp.createLoginRequest(idp, "redirect"); if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" }); - const { state: relayState } = await generateRelayState(ctx, void 0, false); if (loginRequest.id && options?.saml?.enableInResponseToValidation) { const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS; const record = { @@ -2262,7 +2263,7 @@ const signInSSO = (options) => { }); } return ctx.json({ - url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`, + url: loginRequest.context, redirect: true }); } ``` Moving `generateRelayState` before `createLoginRequest` and setting `sp.entitySetting.relayState` lets samlify include it in signature computation. Then `loginRequest.context` already contains the correctly signed `RelayState` param - no need to append it manually. Also worth noting: line 2208 has `parsedSamlConfig.spMetadata.metadata` which crashes if `spMetadata` is undefined - should be `spMetadata?.metadata`.
Author
Owner

@gustavovalverde commented on GitHub (Apr 14, 2026):

Related in the same area (not duplicates): #7549, #6610.

<!-- gh-comment-id:4244778436 --> @gustavovalverde commented on GitHub (Apr 14, 2026): Related in the same area (not duplicates): #7549, #6610.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19584