SAML Redirect Binding: Signature verification fails when RelayState is present #2866

Open
opened 2026-03-13 10:24:36 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @mifopen on GitHub (Feb 13, 2026).

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). 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 bug label 2026-03-13 10:24:36 -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

@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)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2866