Skip to content

fix: Handle WorkOS email verification during managed sign-in#5814

Merged
marcindobry merged 9 commits intomasterfrom
marcin/workos-login-error
Apr 14, 2026
Merged

fix: Handle WorkOS email verification during managed sign-in#5814
marcindobry merged 9 commits intomasterfrom
marcin/workos-login-error

Conversation

@marcindobry
Copy link
Copy Markdown
Contributor

@marcindobry marcindobry commented Apr 8, 2026

WorkOS-managed Google sign-in could fail with email_verification_required, leaving users stuck on the callback and never creating the local Nango user. This adds a managed-auth verification step that stores the pending WorkOS verification state in session, redirects users to /signin/verify, submits the verification code back to WorkOS, and then reuses the existing managed-auth finalization flow.

https://linear.app/nango/issue/NAN-5232/user-cant-sign-in-email-verification-flow-from-workos-not-handled

npm run test:integration -- packages/server/lib/controllers/v1/account/managed/verification.integration.test.ts
npm run ts-build

Screenshot 2026-04-08 at 17 43 24

In addition to handling the verification challenge path, this change also broadens managed-auth support by formalizing the verification lifecycle across backend and frontend boundaries, including reusable managed-auth flow structure and end-to-end coverage to ensure the callback, verification, and completion paths behave consistently across different auth configurations.


This summary was automatically generated by @propel-code-bot

@marcindobry marcindobry changed the title Handle WorkOS email verification during managed sign-in fix: Handle WorkOS email verification during managed sign-in Apr 8, 2026
Copy link
Copy Markdown
Contributor

@propel-code-bot propel-code-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid feature implementation, but two important fixes are needed for secure error handling and resilient session-save failure behavior.

Status: Changes Suggested | Risk: Medium

Issues Identified & Suggestions
  • Prevent upstream error-message leakage; return only sanitized fallback text. packages/server/lib/controllers/v1/account/managed/postVerification.ts
  • Handle saveSession failures locally; redirect with friendly signin error. packages/server/lib/controllers/v1/account/managed/getCallback.ts
Review Details

📁 13 files reviewed | 💬 2 comments

Instruction Files
└── .claude/
    ├── agents/
    │   └── nango-docs-migrator.md
    └── skills

👍 / 👎 individual comments to help improve reviews for you

Comment thread packages/server/lib/controllers/v1/account/managed/postVerification.ts Outdated
Comment on lines +71 to +84
if (
workosErr.rawData?.code === 'email_verification_required' &&
workosErr.rawData.pending_authentication_token &&
workosErr.rawData.email &&
workosErr.rawData.email_verification_id
) {
req.session.managedAuthEmailVerification = {
email: workosErr.rawData.email,
pendingAuthenticationToken: workosErr.rawData.pending_authentication_token,
emailVerificationId: workosErr.rawData.email_verification_id,
state: query.state
};
await saveSession(req);
res.redirect(`${basePublicUrl}/signin/verify`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

[Reliability] If saveSession rejects (e.g., session store is unavailable), the rejection escapes the catch block and asyncWrapper forwards it to Express's global error handler via next(err), producing a 500 instead of redirecting to /signin with a meaningful error.

Wrap saveSession in its own try/catch here to ensure a user-friendly redirect on failure:

Suggested change
if (
workosErr.rawData?.code === 'email_verification_required' &&
workosErr.rawData.pending_authentication_token &&
workosErr.rawData.email &&
workosErr.rawData.email_verification_id
) {
req.session.managedAuthEmailVerification = {
email: workosErr.rawData.email,
pendingAuthenticationToken: workosErr.rawData.pending_authentication_token,
emailVerificationId: workosErr.rawData.email_verification_id,
state: query.state
};
await saveSession(req);
res.redirect(`${basePublicUrl}/signin/verify`);
if (
workosErr.rawData?.code === 'email_verification_required' &&
workosErr.rawData.pending_authentication_token &&
workosErr.rawData.email &&
workosErr.rawData.email_verification_id
) {
req.session.managedAuthEmailVerification = {
email: workosErr.rawData.email,
pendingAuthenticationToken: workosErr.rawData.pending_authentication_token,
emailVerificationId: workosErr.rawData.email_verification_id,
state: query.state
};
try {
await saveSession(req);
res.redirect(`${basePublicUrl}/signin/verify`);
} catch {
res.redirect(`${basePublicUrl}/signin?error=sso_session_expired`);
}
return;
}
Context for Agents
If `saveSession` rejects (e.g., session store is unavailable), the rejection escapes the `catch` block and `asyncWrapper` forwards it to Express's global error handler via `next(err)`, producing a 500 instead of redirecting to `/signin` with a meaningful error.

Wrap `saveSession` in its own try/catch here to ensure a user-friendly redirect on failure:

```suggestion
        if (
            workosErr.rawData?.code === 'email_verification_required' &&
            workosErr.rawData.pending_authentication_token &&
            workosErr.rawData.email &&
            workosErr.rawData.email_verification_id
        ) {
            req.session.managedAuthEmailVerification = {
                email: workosErr.rawData.email,
                pendingAuthenticationToken: workosErr.rawData.pending_authentication_token,
                emailVerificationId: workosErr.rawData.email_verification_id,
                state: query.state
            };
            try {
                await saveSession(req);
                res.redirect(`${basePublicUrl}/signin/verify`);
            } catch {
                res.redirect(`${basePublicUrl}/signin?error=sso_session_expired`);
            }
            return;
        }
```

File: packages/server/lib/controllers/v1/account/managed/getCallback.ts
Line: 84

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc89ed4017

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +53 to +57
await finalizeManagedAuthentication({
req,
res,
authorizedUser: authResponse.user,
organizationId: authResponse.organizationId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Separate WorkOS code errors from local finalization failures

postManagedEmailVerification wraps both authenticateWithEmailVerification and finalizeManagedAuthentication in one try/catch, so any local failure after a valid code (e.g., DB/user creation/login errors inside finalization) is returned as 400 invalid_verification_code. In that case users are told their code is wrong even when WorkOS verification succeeded, and operational failures are masked as client errors; this should only map WorkOS verification failures to 400 and rethrow/propagate non-WorkOS errors.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@propel-code-bot propel-code-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feature implementation, but one important error-handling path and minor maintainability issues should be addressed before merge.

Status: Changes Suggested | Risk: Medium

Issues Identified & Suggestions
  • Prevent swallowing unexpected WorkOS errors; rethrow non-verification codes: packages/server/lib/controllers/v1/account/managed/postVerification.ts
  • Trim constructed display names to avoid trailing whitespace artifacts: packages/server/lib/controllers/v1/account/managed/utils.ts
  • Avoid tagging expected verification flow as error-like telemetry noise: packages/server/lib/controllers/v1/account/managed/getCallback.ts
Review Details

📁 13 files reviewed | 💬 3 comments

Instruction Files
└── .claude/
    ├── agents/
    │   └── nango-docs-migrator.md
    └── skills

👍 / 👎 individual comments to help improve reviews for you

Comment on lines +66 to +70
const updatedVerification = getManagedAuthEmailVerificationFromError(err);

if (updatedVerification) {
await setManagedAuthEmailVerification(req, updatedVerification, verification.state);
} else if (!workosErr.rawData) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

[Logic] The error handling logic has an ambiguous silent-swallow path. When getManagedAuthEmailVerificationFromError(err) returns null AND workosErr.rawData is truthy (i.e. WorkOS returned a structured error that is NOT email_verification_required), the code falls through to return 400 invalid_verification_code without rethrowing. This means genuine WorkOS API errors — like rate_limit_exceeded, client_id_invalid, or other structured WorkOS errors — are silently swallowed and presented to the user as an invalid verification code, making them impossible to debug.

The condition should be inverted: rethrow if the error is unexpected, return 400 only for known invalid-code errors:

Suggested change
const updatedVerification = getManagedAuthEmailVerificationFromError(err);
if (updatedVerification) {
await setManagedAuthEmailVerification(req, updatedVerification, verification.state);
} else if (!workosErr.rawData) {
if (updatedVerification) {
await setManagedAuthEmailVerification(req, updatedVerification, verification.state);
} else if (workosErr.rawData?.code !== 'email_verification_required' && workosErr.rawData?.code !== 'verification_code_expired' && workosErr.rawData?.code !== 'verification_code_invalid') {
throw err;
}

Alternatively, explicitly enumerate the codes that should return 400 rather than relying on the presence of rawData.

Context for Agents
The error handling logic has an ambiguous silent-swallow path. When `getManagedAuthEmailVerificationFromError(err)` returns `null` AND `workosErr.rawData` is truthy (i.e. WorkOS returned a structured error that is NOT `email_verification_required`), the code falls through to return `400 invalid_verification_code` without rethrowing. This means genuine WorkOS API errors — like `rate_limit_exceeded`, `client_id_invalid`, or other structured WorkOS errors — are silently swallowed and presented to the user as an invalid verification code, making them impossible to debug.

The condition should be inverted: rethrow if the error is unexpected, return 400 only for known invalid-code errors:
```suggestion
        if (updatedVerification) {
            await setManagedAuthEmailVerification(req, updatedVerification, verification.state);
        } else if (workosErr.rawData?.code !== 'email_verification_required' && workosErr.rawData?.code !== 'verification_code_expired' && workosErr.rawData?.code !== 'verification_code_invalid') {
            throw err;
        }
```
Alternatively, explicitly enumerate the codes that should return 400 rather than relying on the presence of `rawData`.

File: packages/server/lib/controllers/v1/account/managed/postVerification.ts
Line: 70

Comment thread packages/server/lib/controllers/v1/account/managed/auth.ts
Comment on lines +71 to +73
const verification = getManagedAuthEmailVerificationFromError(err);
if (verification) {
await setManagedAuthEmailVerification(req, verification, query.state);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommended

[Maintainability] The span tags for workos.error_code and workos.error_message are set unconditionally for all errors (lines 60-68), including email_verification_required, before the verification check on line 71. Since this PR introduces email_verification_required as an expected, user-facing flow (not a server error), the verification-required case will create trace spans tagged with error-like metadata. While these custom tags don't inherently mark the span as errored in dd-trace (that requires span.setTag('error', true)), they could create noise in any dashboards or queries filtering on workos.error_code presence. Consider moving the getManagedAuthEmailVerificationFromError(err) check before the span tag logic, or setting a different tag (e.g. workos.flow = 'email_verification') for the expected verification case.

Context for Agents
The span tags for `workos.error_code` and `workos.error_message` are set unconditionally for all errors (lines 60-68), including `email_verification_required`, before the verification check on line 71. Since this PR introduces `email_verification_required` as an expected, user-facing flow (not a server error), the verification-required case will create trace spans tagged with error-like metadata. While these custom tags don't inherently mark the span as errored in dd-trace (that requires `span.setTag('error', true)`), they could create noise in any dashboards or queries filtering on `workos.error_code` presence. Consider moving the `getManagedAuthEmailVerificationFromError(err)` check before the span tag logic, or setting a different tag (e.g. `workos.flow = 'email_verification'`) for the expected verification case.

File: packages/server/lib/controllers/v1/account/managed/getCallback.ts
Line: 73

return metadata;
}

export async function finalizeManagedAuthentication({
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly the code from getCallback so it can be reused

declare module 'express-session' {
interface SessionData {
debugMode?: boolean;
managedAuthEmailVerification?: {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't figure out a different way of storing this data securely across requests. Any patterns we use already?

@marcindobry marcindobry requested a review from a team April 8, 2026 19:00
}

if (invitation) {
await acceptInvitation(invitation.token);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can acceptInvitation throw? if yes what's responded?


const validation = z
.object({
code: z.string().trim().min(6).max(12)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is max(12) just in case?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is utils the new misc? 😉

what about auth.ts or evenmanaged.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like auth.ts :)
And yep, seems like "if in doubt, util.ts out"


interface WorkOSOrganizationClient {
getOrganization(organizationId: string): Promise<{ name: string }>;
}
Copy link
Copy Markdown
Collaborator

@TBonnin TBonnin Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it not exported from WorkOS sdk?

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 573e790ce2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

});
}

if (globalEnv.features.auth) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve /signup fallback for managed-auth callback errors

This change makes /signup available only when globalEnv.features.auth is true, but getManagedCallback still redirects invalid WorkOS callback payloads to /signup in its validation-failure branch (packages/server/lib/controllers/v1/account/managed/getCallback.ts). In managed-auth-only deployments (features.auth=false, features.managedAuth=true), a callback without a code (for example after an OAuth/provider error) now routes users to a non-existent page instead of a recoverable sign-in screen.

Useful? React with 👍 / 👎.

@marcindobry marcindobry added this pull request to the merge queue Apr 14, 2026
Merged via the queue into master with commit a2a5f3c Apr 14, 2026
24 of 25 checks passed
@marcindobry marcindobry deleted the marcin/workos-login-error branch April 14, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants