EXT:felogin 

EXT:felogin works out of the box with headless. The headless XClass on LoginController swaps the HTML view for a JSON response that includes the form definition, the login status and any redirect target.

Setup 

Standard felogin setup — install (composer require typo3/cms-felogin), drop a login plugin onto a page, configure storage pages in plugin settings.

You can test the flow without a frontend with curl. TYPO3 v14 only evaluates credentials that are accompanied by a valid, nonce-signed __RequestToken, so a login is always two requests: fetch the form first, then post it back together with its cookies.

# 1) Fetch the login form; store the typo3nonce_* cookie
curl -s -c cookies.txt -H 'Accept: application/json' \
  https://api.example.com/login-page

# 2) Post credentials plus EVERY hidden field returned in
#    form.elements, sending the cookies back — the __RequestToken
#    is only valid together with its typo3nonce_* cookie
curl -i -b cookies.txt -c cookies.txt -X POST \
  -H 'Accept: application/json' \
  https://api.example.com/login-page \
  --data-urlencode 'user=joe' \
  --data-urlencode 'pass=secret' \
  --data-urlencode 'logintype=login' \
  --data-urlencode 'pid=<value from form.elements>' \
  --data-urlencode '__RequestToken=<value from form.elements>'
  # …plus all remaining hidden fields from form.elements, verbatim
Copied!

A successful login responds with a set-cookie header carrying the session cookie (fe_typo_user). On failure the JSON status flips to failure and message carries the rendered header/text for the failure state.

JSON response shape 

The plugin output sits under the content element's content.data key:

{
  "content": {
    "colPos0": [{
      "type": "felogin_login",
      "content": {
        "data": {
          "form": {
            "title": "Login",
            "action": "https://api.example.com/login-page",
            "method": "POST",
            "elements": []
          },
          "message": { "header": "…", "message": "…" },
          "status": "success",
          "recovery": "https://api.example.com/login-page?…",
          "flashMessages": []
        }
      }
    }]
  }
}
Copied!

recovery always carries the password-recovery URI (never null).

form.elements contains every input the frontend must submit. Echo each hidden field back verbatim, under its exact name:

  • logintype, pid, redirect_url, noredirect, redirectReferrer, referer and — when permalogin is offered — permalogin: plain felogin fields, unprefixed.
  • tx_felogin_login[__referrer][@extension], [@controller], [@action], [arguments], [@request]: extbase referrer fields, HMAC-protected.
  • tx_felogin_login[__trustedProperties]: the extbase property mapping token. It is emitted with the plugin prefix and must be posted back under that prefixed name.
  • __RequestToken: a TYPO3 RequestToken with scope core/user-auth/fe, signed against the typo3nonce_* cookie set on the GET request. This is the one field that stays unprefixed.

Without a complete, unmodified set of these fields the login is rejected before credentials are even checked.

When a redirect target applies, the plugin payload is replaced entirely by { "redirectUrl": "…", "statusCode": 303, "status": "…" } — the frontend performs the redirect itself.

Cookies & cross-domain 

The session cookie is scoped to the API domain by default. If your frontend lives on a different host, set a shared $GLOBALS['TYPO3_CONF_VARS']['FE']['cookieDomain'], or enable the headless.cookieDomainPerSite feature flag to derive it per site — see Multi-Site & URL Configuration.

Detect login in your own code 

use TYPO3\CMS\Core\Context\Context;

$context = GeneralUtility::makeInstance(Context::class);
$isLoggedIn = $context->getPropertyFromAspect('frontend.user', 'isLoggedIn');
Copied!

Or for the user's data:

$userId = (int)$context->getPropertyFromAspect('frontend.user', 'id');
$groups = $context->getPropertyFromAspect('frontend.user', 'groupIds');
Copied!

Listening for successful login 

Headless ships LoginConfirmedEventListener that decorates the JSON view with status = success. To run your own logic at the same point, listen to TYPO3 core's TYPO3CMSFrontendLoginEventLoginConfirmedEvent — see Events you can listen to for listener registration.