Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

Troubleshooting

Symptom → cause → fix, for developers integrating Zynth Auth.

301 / unexpected redirect from auth.zynthmedia.com

You're requesting a website path from the application origin. Marketing pages (/pricing, /trust, /self-hosted, /legal/*) plus /llms.txt and /sitemap.xml moved to https://zynthmedia.com and now 301 there permanently. Browsers and well-behaved clients follow it; a client configured not to follow redirects (or a link checker treating a 301 as a failure) will surface this. Point at the apex directly, and use curl -L if you must fetch one of these from the old path. API paths (/api/v1/*), /.well-known/*, and /docs* are unaffected — they have always lived, and still live, on the application origin. See Where things live.

403 on signup — "private beta"

Public sign-up is closed by default while Zynth Auth is in its private beta (PUBLIC_SIGNUP_ENABLED=false): POST /api/v1/auth/signup answers 403 with a reason naming the early-access page. Invited testers get in through their invite — see Joining the private beta. A self-hosted install that wants open sign-up sets PUBLIC_SIGNUP_ENABLED=true. Existing accounts always sign in; only the creation of new organizations is closed.

403 on forgot-password — "reset is closed"

PASSWORD_RESET_ENABLED ships true, so a self-hosted install has self-service reset working out of the box. Zynth's own deployment sets it false during the private beta: then POST /api/v1/auth/forgot-password answers 403 naming who to contact, and the console's reset form renders disabled with the same reason. The refusal is the same for a registered and an unknown address — a 403 tells you nothing about whether the account exists.

If you have forgotten your password on Zynth's beta: use Email me a sign-in link on the sign-in page. Magic-link sign-in is not gated by this flag and gets you back in; ask Zynth to reset the password itself. If you are self-hosting and got this unexpectedly: someone set PASSWORD_RESET_ENABLED=false; set it back to true and recreate the backend container. A reset link already issued keeps working either way — /auth/reset-password is not gated.

401 Invalid credentials on login

The email, password, or organization slug is wrong — the response is deliberately generic and doesn't say which. Check that tenant_slug matches the organization the user belongs to (it's derived from the org name, e.g. "Acme Inc" → acme-inc).

422 Unprocessable Entity on signup or login

A required field is missing or malformed. Signup needs email, password, and organization_name; login needs email, password, and tenant_slug. Check field names exactly.

429 Too Many Requests on login

Too many failed sign-in attempts from your IP or against one account tripped the login throttle. Wait the number of seconds in the Retry-After header, then try again — a successful login clears the account's counter. If a legitimate user is hitting this, it's usually a wrong saved password; have them reset it. Normal (successful) logins never count against the limit.

403 on login — "This organization requires enterprise SSO"

The organization has SSO required turned on: members must sign in through the tenant's identity provider, so password / magic-link / passkey / social sign-in are refused for them. Use the Sign in with enterprise SSO button (or your IdP's app tile). Organization owners keep a break-glass password/passkey sign-in for when the IdP is unavailable.

413 Request Entity Too Large

The request body exceeded the 1 MB edge limit. Auth and admin payloads are far smaller than this — a 413 almost always means a malformed or accidentally-huge body. (A deeply-nested or malformed JSON body returns a clean 400 instead.)

400 on signup — weak password

The password failed the policy: at least 12 characters, at most 128, and not a common password. The error message states the reason.

409 on signup

Either the email is already registered, or the organization name resolves to a slug that's already taken. Use a different organization name, or log in instead.

Login returned { mfa_required: true } instead of tokens

The account has MFA enabled — this is the two-step flow, not an error. Complete the login at POST /api/v1/mfa/challenge with the mfa_token from this response plus a TOTP (or single-use recovery) code. The challenge token is single-use and short-lived (~5 min, max 5 attempts): a 401 on /mfa/challenge means it's spent or expired — send the user back to log in again for a fresh one.

refresh_token is null in the response

You (or the browser client) sent use_cookie: true. In cookie mode the refresh token is set as an httpOnly cookie instead of returned in the body — call refresh with an empty body and it uses the cookie. API/SDK clients that want the token in the body should omit use_cookie.

Passkey sign-in fails

  • Everything fails / rpId looks wrong (self-hosted) — the server's WEBAUTHN_RP_ID must be your registrable domain (e.g. example.com, not auth.example.com) and WEBAUTHN_ORIGIN the full site origin. A mismatch breaks every ceremony. Note the RP ID is effectively permanent once users enroll passkeys — changing it later orphans them.
  • 401 Passkey sign-in failed — generic by design (unknown credential, bad assertion, wrong tenant_slug, replayed/expired challenge, or a suspected cloned authenticator). Fetch a fresh challenge from /webauthn/login/options per attempt; confirm the passkey was registered against this account and you're passing the right organization slug.
  • A user-verified passkey does not prompt for a TOTP code — expected: a UV passkey is two factors in one ceremony, so passkey logins skip the MFA challenge.

Your organization's administrators can now turn individual sign-in methods off (Settings → Organization): emailed magic links and social sign-in (Google/GitHub) are each a switch. When a method is disabled, magic-link requests still return the normal "check your email" response (we never reveal account or configuration details on that surface) but no link is sent, and previously issued links stop redeeming; social sign-in returns the same generic refusal as any other unavailable method. If a method your team relies on stopped working org-wide, ask an administrator to check those switches before filing a ticket.

Magic links are deliberately strict: each link works once, expires after ~15 minutes, and requesting a new link cancels the old one (if you tapped "email me a link" twice, only the newest email works). A link that was already opened — including by a corporate mail scanner that follows links — still requires the final "Continue sign-in" click to be used, so scanners don't burn it; but once redeemed anywhere it is dead. Request a fresh link from the sign-in page. If you have two-factor enabled, you'll still be asked for your code — that's by design.

"Continue with Google/GitHub" fails or bounces back to login

Social sign-in failures are deliberately generic (one error flag, no detail). The usual causes, in order:

  • The organization hasn't enabled customer self-signup — new users can only be created via social login if the target organization has customer signup enabled. Same rule as the signup API, same non-enumerating behaviour.
  • Your provider email isn't verified — we refuse to auto-link a provider account to an existing Zynth Auth account unless the provider asserts the email is verified (this blocks account-takeover via unverified provider emails). Verify the email at the provider and retry.
  • The flow went stale — the sign-in round-trip must complete within ~10 minutes and the final handoff within 60 seconds; both are single-use. Just start again.
  • A TOTP prompt after social sign-in is expected — social login never bypasses an enrolled second factor.
  • Self-hosted: the provider isn't configured (404 from the start endpoint — set the client id/secret pair), or the provider hosts aren't on the egress allowlist.

Customer sign-up returns 403

Customer (CIAM) self-sign-up is opt-in per organization and defaults off. Enable it for the tenant via PATCH /api/v1/tenants/settings (ciam_signup_enabled: true, needs the tenant:manage permission). An unknown organization and a disabled one return the same 403 on purpose (no enumeration).

Verification email never arrives

Customer sign-up dispatches an email-verification link, but login is not blocked on itemail_verified on GET /me is informational for now. If the mail doesn't arrive: check spam first (transactional mail sends from send.-prefixed subdomains); on a self-hosted instance, confirm the email provider is configured; the token is valid for 48 h and POST /api/v1/auth/resend-verification (always 202) re-sends it. The verification page confirms the address on a click — like sign-in links, the token is single-use and the page never spends it just by being opened, so a mail scanner that follows the link doesn't burn it.

Password-reset email never arrives

POST /api/v1/auth/forgot-password always answers 202 whether or not the address has an account (no enumeration) — so "no email" can simply mean no account under that exact address. Confirm the spelling and the spam folder; the link expires after ~30 minutes, so request a fresh one if it lapsed. On a self-hosted instance, verify email delivery configuration — every send is recorded, and failing sends raise an "Email delivery degraded" detection in the Command Center.

Token verification fails in my service

  • kid not found in JWKS — you're likely holding a stale JWKS after a key rotation. Refetch /.well-known/jwks.json once and retry before failing.

  • Signature/alg errors — pin algorithms=["RS256"]; don't accept the token's own alg.

  • aud/iss mismatch — first identify which token you are validating; the two types never share values:

    • Access token: iss = zynth-auth; aud = zynth-services — unless it was minted for a registered resource, in which case aud is that resource identifier and the token is valid only there.
    • id_token: iss = the issuer URL from discovery (https://auth.zynthmedia.com on the managed service); aud = your client_id.

    Validating both token types through one shared issuer/audience config is the most common cause of this error — including verifiers that derive the expected issuer from the discovery document and then reject every access token. See which iss.

  • See Verifying tokens for the full recipe.

/userinfo returns 401 but the token looks valid

Two by-design causes:

  • The session behind the token was revoked — logout, "sign out everywhere", a password change, or the user disconnecting your app. UserInfo honors revocation immediately, even inside the token's 15-minute lifetime.
  • The token is resource-bound — minted with a resource parameter. Such a token verifies only at its named resource and is rejected by the Zynth API itself, including /userinfo. If you need a per-request revocation check alongside resource-bound tokens, see the trade-off.

401 right after login, ~15 minutes in

The access token expired (15-minute default). Use the refresh token against POST /api/v1/auth/refresh to get a new pair. (The console does this silently; when the refresh itself is refused — the session was revoked or its refresh token expired — it sends you to sign in and brings you back to the page you were on. That return target is always a page on the console's own origin: a sign-in link cannot be crafted to forward a freshly signed-in user to another site.)

A user was signed out everywhere and nobody did it

Check, in this order:

  1. A password change or reset — every token issued before it is rejected, on every device.

  2. Refresh-token reuse detection — an already-used refresh token was presented, which revokes the whole session. Usually a client that persisted the old token after a refresh; see Rotation & reuse detection.

  3. The identity provider — SCIM deactivation, SAML single logout, or OIDC back-channel logout all revoke immediately.

  4. Autonomous response — if your organization has raised a response level, the platform may have ended the sessions itself in response to a security signal. This is the one with no human in the trail, so it is the one people don't think to check. Look in Command Center → Autonomy, or:

    curl -H "Authorization: Bearer $TOKEN" \
      "https://auth.example.com/api/v1/autonomy/decisions?mode=executed"
    

    Each row names the detection that triggered it, the user, and the exact effect. If it was wrong, undo it — reverting restores the previous state, so the user's existing devices resume working without signing in again — and say why: reversals hold that response class below unattended operation until the record is clean again.

Webhooks aren't arriving

Send a test delivery first (Settings → Webhooks → Send test) — it answers in one step:

  • blocked_by_policy: true — your URL resolves to a private or internal address, so we never opened the connection. Use a publicly resolvable hostname. This cannot be waived.
  • timeout — we connected and your endpoint didn't answer within 10 seconds. Return 2xx immediately and process asynchronously.
  • http_status — your endpoint answered 4xx/5xx. Check your own logs.
  • Delivered, but you see nothing — the usual causes are an endpoint with an empty event_types (it receives nothing until you subscribe), or your handler rejecting our signature because it verified a re-serialised body instead of the raw bytes.

Also check whether the endpoint is still enabled: after 20 consecutive failures we disable it automatically, and it stays off until you re-enable it. Full detail in the Webhooks guide.

Signature verification always fails on my webhook handler

You are almost certainly hashing a re-serialised body. The signature covers the exact raw bytes we sent, so parsing the JSON and re-encoding it changes key order and whitespace and can never match. Capture the raw body before any JSON middleware — in Express that means express.raw({ type: "application/json" }) on the webhook route.

The second most common cause is ignoring the timestamp: the signed material is t.body, not body alone. See Verify the signature.

Browser warns "your connection is not private" (self-hosted)

Expected when the instance uses a self-signed certificate (IP-only, no domain). The connection is still encrypted; point a domain at the host for a browser-trusted certificate. See Self-hosting.

Self-hosted: outbound calls fail (AI docs or email)

A self-hosted instance is default-deny for outbound traffic — application containers reach the internet only through an egress allowlist proxy. If the AI documentation assistant returns errors, or emails don't send, the most likely cause is that the destination isn't on the allowlist (the reference allowlist already includes the managed email API, api.resend.com). Add the host to the proxy allowlist and redeploy the proxy. Note the proxy forwards HTTPS only — a BYO SMTP relay (ports 587/465) needs its own network path. This is intentional containment, not a bug — nothing leaves the instance to an unapproved host.

Agent onboarding: the bootstrap never completes

The agent polls and keeps getting authorization_pending, or the attempt dies:

  • 404 on every /api/v1/onboard/* call — agent-driven onboarding is not enabled on this deployment (ONBOARD_BOOTSTRAP_ENABLED; see dormant capabilities). On managed cloud, ask support.
  • 429 with slow_down — you polled faster than the interval the bootstrap response gave you. Honour it; the interval is not advisory.
  • 410 on poll — the request expired before the human finished. Its window is minutes, not days (about an hour by default) and is stated as expires_in in the bootstrap response — do not confuse it with the 48-hour validity of the email link inside it. Just bootstrap again with the same email: a repeat request rotates fresh codes and retires the old ones.
  • The human says they approved, but the poll still pends — approval is refused until the owner has a sign-in method (a passkey or password). The wizard's Approve button stays disabled until then, deliberately: an organization whose only owner cannot sign in is a locked room.
  • The credential came back once and now the poll says consumed — correct and final. The agent credential is released to exactly one poll, ever. If you lost it, the agent exists; rotate its credential from the console rather than re-running the bootstrap.

Agent onboarding: the new organization can't do anything

Invitations, OAuth clients, webhooks and API keys all answer 403 with a message about finishing setup. The tenant is quarantined — it was created by an agent and its human anchor has not approved yet. Sign-in and the verification flow stay open; everything that grows or reaches out of the organization waits. Approving (or denying) on the verification page lifts it. This is a 403 with an actionable reason, never a 404 — you are not being told the feature doesn't exist.

apply_manifest / POST /api/v1/manifest/apply is refused

  • 403, "no approval for these bytes" — either nobody has decided the ask yet, or the manifest changed after they did. The approval binds to the document's content hash, so any edit invalidates it. Re-plan, confirm the manifest_hash, ask again.
  • 409 with a list of blockers — live state now conflicts with the approved document (typically: a role it declares already exists with a different definition; version 1 never updates in place). Each blocker names its fix. Your approval is not spent by a refusal.
  • 502, "apply stopped at …" — a step failed mid-run. Re-apply the same manifest: the finished objects re-plan as no-ops and execution continues from the first unfinished step. Live state is the checkpoint, so resuming is just retrying.
  • Applied fewer steps than expected, resumed: true — not an error. Steps already satisfied are skipped and counted in skipped; a re-apply of an unchanged manifest is a no-op that confirms the original decision.

An agent can't see the tools it should have

tools/list is filtered to the agent's delegated permissions, so a missing tool is a missing delegation — an agent with none sees only whoami. Agents released by an onboarding bootstrap are delegated the manifest tools automatically, but only for a bounded window (7 days by default); after that, delegate explicitly from the console. See Operating Zynth over MCP.