Troubleshooting · Developer docs

Troubleshooting

Grouped by symptom, never by module, because the person with the problem does not know which module has it. One hundred questions across ten pages, each answered with the causes in the order they usually turn out to be true.

Symptom 9 questions

Members and authentication

Nine sign-in, session and access problems on member sites, each with its causes in the order they usually turn out to be true, and the check that tells them apart.

Most member problems come from three facts. A page can be served from the cache to someone it was not rendered for. A sign-in is a cookie for one hostname, and it lasts twelve hours. And an administrator passes every check a member fails. So test as a member, in a private window, on the hostname members use. A test run in your own admin session proves nothing here.

A member can log in but sees the logged-out header

The sign-in worked, because /my-account opens, but the header still says "Sign in". In order:

  1. The page came from the cache. The page cache is read before the platform knows who is asking, and its key ignores cookies. Once an anonymous visitor has requested a URL, everyone gets that copy, members included. Check the response headers: X-Cache-Status: hit means a cached copy. Load the personal part of the header as a fragment, or prevent caching on pages whose output depends on the visitor (member pages and the page cache).
  2. A different hostname. The sign-in cookie, token, is set for the exact hostname. A member signed in on example.com is signed out on www.example.com. Redirect every hostname to one.
  3. The template tests the wrong thing. isFrontendUser() is also true for a signed-out visitor, because the default type is frontend. Test isLoggedIn().
  4. The fragment is cached too. A script that fetches the header's personal part must fetch it from a page that is never cached for a member, such as one under /my-account.
Template code
{if $_currentUser->isLoggedIn()}
  <a href="{$_url->getPath('account')}">My account</a>
{else}
  <a href="{$_url->getPath('login')}">Sign in</a>
{/if}
{% if _currentUser.isLoggedIn() %}
  <a href="{{ _url.getPath('account') }}">My account</a>
{% else %}
  <a href="{{ _url.getPath('login') }}">Sign in</a>
{% endif %}

That test is correct, but it only helps on a page that is not served from the cache.

Open this answer on its own page →
A member is logged out at random

It is rarely random. In order:

  1. Twelve hours have passed since they signed in. A member's sign-in lasts twelve hours from the moment they signed in, whatever they do in between. The session length and "Extend session while in use" settings in the OAuth configuration apply to admin sessions only. No setting changes a member's twelve hours. Remember me does not help a password member either: it only renews sessions that came from a single sign-on provider.
  2. They moved between hostnames. A link in an email points at the bare domain while the site runs on www, for example. Each hostname has its own sign-in.
  3. They were signed out from somewhere else. Changing the password, or ending a session from /my-account/current-activity, ends every other session of that member. If two people share an account, a password change on one device signs out the other.
  4. It only looks like a sign-out. A cached page shows the signed-out header (above). A page reached by a form posted from another site, such as a payment provider's return, arrives without cookies, because they are SameSite=Lax. A site inside another site's iframe cannot keep anyone signed in. Open /my-account in the same tab: if it opens, they are still signed in.

A submit refused with 403, "Missing or invalid CSRF token", also looks like a sign-out but is not one. The member is still signed in, and their PHP session was lost. Either way, what they typed is gone unless the page saved it: when a session runs out mid-form.

Open this answer on its own page →
The password reset email never arrives

The form always says a link has been sent, whether or not one was, so that it cannot be used to find out who has an account. So first, find out whether the platform sent one at all:

  1. The address has no login. A reset goes to the person with that email address only if the person has a user. A guest who bought something, a form submitter or an imported customer is a person without one, and gets nothing. Registering does not help them either, because registration refuses an address that already belongs to a person ("This email is already used."). An administrator has to add the login (guest and account checkout).
  2. The login is not active. Inactive and disabled users get nothing.
  3. Password sign-in is switched off for the site. In 2026.5.2 the forgotten-password page then redirects away, and nothing can be requested.
  4. It was sent somewhere else. On any environment other than production, the platform sends mail to the development address unless the recipient matches its whitelist.
  5. It was sent and filtered as spam. The default sender is no-reply@coredna.com, named "Core dna DXP", with the subject "Core dna: Password Reset". In the Users settings, set the sender to an address on the client's own domain, authenticated with SPF and DKIM, and change the subject.

One related case is not silent: a Twig site needs templates/users/emails/reset-password-twig.html deployed, or a reset request for a real, active address answers a server error instead of the usual message. A member who signs in only with Google or Microsoft does get the email, and setting the new password also switches password sign-in on for their account, so they can use either afterwards (forgotten and reset passwords).

Open this answer on its own page →
A frontend user can reach an admin URL

First, decide what "reach" means. /cdna-admin is meant to be reachable: anyone, signed in or not, gets its sign-in page. What must not happen is a member signing in there, or getting admin data. Check in order:

  1. The account is not a frontend user. The admin turns away only users of type frontend. Every other type can sign in, including backend, which reaches the admin with whatever permissions the site gives it. Check the user's type in the admin.
  2. Registration creates backend users. The registration Type setting offers backend as well as frontend. Set to backend, it gives every visitor who registers an admin login. It must be frontend.
  3. It is the site's own page, such as /staff, not the platform's admin. A page is public unless its record is restricted to a group, and a check in the template is not enough (template gating and route gating).
  4. It is the API. A signed-in member who calls a default API route is refused: with a 404 by default, or a 403 if the security setting "Access Denied (Authenticated)" is set to "Show Forbidden error (403)".
Open this answer on its own page →
A custom my-account page is not found

A 404 under /my-account/ is about the file, because a signed-out visitor is sent to sign in rather than shown a 404. The rules for the file are under 404 on a page under /my-account/: it must be in your site's folder, at the URL's path plus .html, with no index file, no dot in the path, and the same case. A custom page can also go missing in these ways:

  • A built-in screen appears instead. The named routes win. A page called orders, order, quotes, quote, addresses, address, wishlist, linked-accounts, applications or current-activity, or in a folder with one of those names, never renders. Rename it.
  • The id is in the path. /my-account/course/42 looks for course/42.html. Use /my-account/course?id=42 and read the parameter in the template.
  • The page posts a form to itself. The catch-all answers GET only, so a POST never reaches your template. Post to a real route.
  • It has not been deployed there. A my-account page is a file in the repository, not a record. A page that works on staging and 404s on production has not been deployed to production.

The full rules are on the my-account area.

Open this answer on its own page →
Sign-in with an external provider returns to an error page

Where the error appears tells you which side failed.

  • At the provider. Google, Microsoft or Facebook shows an error and the member never comes back: the redirect URI is not registered for the hostname they are on. Register https://<hostname>/oauth/callback/<provider> for every hostname members can sign in from, including aliases, staging and development. The admin's OAuth App Setup tab lists them.
  • A server error as soon as the button is pressed. The provider was switched on before its client ID and secret were filled in.
  • A 404 on the button. The provider is switched off and the button is hardcoded. Build the buttons from enabledOAuthAuthenticators instead.
  • Your site's errors/oauth.html, with HTTP 400. The handshake failed on the way back, and $code says how:
    • invalid_state: the session was lost between leaving and returning. Usually the callback's hostname differs from the one the member started on, so the browser did not send the session cookie. Or they took too long, or finished in another browser.
    • access_denied: they pressed Cancel at the provider.
    • authorization_failed_or_incomplete: the code exchange failed. A wrong or expired client secret, or for Microsoft the wrong tenant.
    • user_authenticator_already_exists_for_user: an account with that email already has another way in. They sign in that way, then connect the provider on the linked-accounts screen.
  • Facebook only: a Facebook account with no email address cannot sign in.

A sign-in that succeeds but creates a second account is a matching problem. An incoming sign-in is matched by email address against usernames, so a member whose username is not their email address gets a new user (which providers are safe to enable).

Open this answer on its own page →
A member cannot unlink their last authenticator

That is deliberate. Removing a link that would leave the member with no way to sign in is refused with "At least one category one user authenticator must be enabled". To move from one way in to another, connect the new one first, then remove the old one. Connecting password sign-in (local) on the linked-accounts screen is how a Google-only member gets a password.

The rule has two edges:

  • The count includes links to providers that have since been switched off. A member with a password and a link to a withdrawn provider can remove the password, and is then left with no usable way in. If a provider is ever withdrawn, keep password sign-in available, and tell members before it goes.
  • Only an administrator can remove the Core dna link. This matters only for staff accounts.

Unlinking never closes an account. To close one, an administrator deletes the user or sets its status to disabled.

Open this answer on its own page →
An API call works in the browser but fails from a script

The browser was not making the same request. It sent the session cookie of whoever was signed in, often an administrator, and the script sends nothing, or something else. Read the status first: 401 means no credential was found, and 403 means one was found and refused.

  • 401 from the script. It sends no credential. Send Authorization: Bearer with an OAuth access token for a backend user made for the integration, or that user's API key, the older option. Never copy a browser's cookie into a script.
  • 403, "Token doesn't exist or is not valid". The OAuth access token has expired, after four hours, or was never issued here: refresh it.
  • 403, "Authorization method not supported". The scheme must be written exactly Bearer or Basic. bearer and Token are refused.
  • 403, "Authentication failed". The API key does not exist on this environment. Keys are stored per environment, so a staging key does not work on production.
  • Works for your admin login, fails for the integration's credential. Its user has fewer permissions, or an API key has an IP list that does not include the server's address.
  • A write fails from a script that sends a session cookie. A cookie-authenticated POST, PUT or DELETE needs the CSRF token as X-Csrf-Token. With an Authorization header the check does not apply.
  • The browser test was signed in as an administrator, and the real caller is a member. A member is refused on everything except the visitor routes.
  • The script runs in a browser on another domain. The platform sends no cross-origin headers, so a page on another domain cannot read the response at all. Call the API from your server.
Shell
curl -i "https://www.example.com/cdna-api/pages/pages?limit=1" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Accept: application/json"

-i prints the status and headers. On a 401 with an empty body, the x-reason header says why. Which credential suits which client: choosing one.

Open this answer on its own page →
OAuth returns an invalid client error

Two different things are called OAuth on a Core dna site, and the error means something different in each.

Your application gets tokens from the site (the site is the OAuth server):

  • The client's credentials are in a Basic header. The site's metadata lists client_secret_basic, but the token endpoint reads client_id and client_secret from the request body only. A Basic header is taken as a backend user's username and password instead, and the request is refused with a 403 HTML page before the OAuth server sees it. Send both in the body. Many OAuth libraries default to Basic, so set this explicitly. (Checked on two sites, 5 October 2026.)
  • The client id does not exist here. The answer is HTTP 400, {"error":"invalid_client"}. Clients are stored per environment, so an id registered on staging does not exist on production.
  • A confidential client sent no secret, or an old one. A client registered with a secret must send it on every token request, refreshes included. Regenerating the secret in the admin invalidates the old one at once.

At the authorize step, an unknown client id or an unregistered redirect URI is not sent back to your application: the site shows the error itself, because it cannot trust a redirect URI it cannot match to a client.

Shell
curl https://www.example.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d redirect_uri=https://app.example.org/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d code_verifier=THE_VERIFIER

The site signs members in with Google, Microsoft or Facebook (the site is the client): the error comes from the provider. Google shows it on its own page when the client ID is wrong or the OAuth client was deleted. A Microsoft secret that is wrong or has expired fails the code exchange, and the site shows authorization_failed_or_incomplete. Copy the ID and secret again from the provider's console. Microsoft secrets expire on a date chosen when they are created, so record it and renew the secret before then.

In the API reference: Exchange the code for tokens.

Open this answer on its own page →
Go deeper

Was this page helpful?

Your answer goes to the docs team.