FlexPoint Docs
Engineering

Authentication

How users sign in — MLM-backed credential checks, the dual Sanctum + Portal-JWT session, httpOnly cookies at the BFF, and the two-factor challenge.

Every user entry point flows through one pipeline: MeridianLink (MLM) validates the credentials, the portal issues its own session, and the Next.js BFF holds the tokens in httpOnly cookies so the browser never touches them.

MLM is the identity authority

The portal does not store or verify user passwords. Login credentials are checked against MLM's SOAP auth service; a successful check yields an MLM session ticket that the portal caches per session and replays for every downstream MLM read/write on that user's behalf.

The two-token session

A logged-in session carries two tokens, minted together by App\Actions\Identity\IssueApiToken:

TokenTypePurposeTransport
API tokenSanctum personal access tokenAuthenticates the request (auth:sanctum).X-Api-Token response header → httpOnly cookie
Portal JWTHS256 JWT (PortalJwtService)Second-factor binding: proves the request rides the same session that Sanctum token was minted for.X-Portal-Jwt response header → httpOnly cookie

Both are returned to the BFF in response headers and never in the JSON body. The Portal JWT's jti is linked to the Sanctum token id in cache (portal:jwt:token:{tokenId}); the portal.jwt middleware (ValidatePortalJwt) rejects any authenticated request whose X-Portal-Jwt doesn't match the expected jti and sub.

Internal vs external users

Two UserTypes authenticate against different MLM operations:

Internal (UserType::Internal)External (UserType::External)
WhoEmployeesBroker / correspondent (PML) users
ActionAuthenticateInternalUserAuthenticateExternalUser
MLM opinternalAuthTicket(username, password)externalAuthTicket(username, password, customerCode)
Extracustomer_code (e.g. PML0368); channel selection for multi-role users
EndpointPOST /api/v1/auth/internal/loginPOST /api/v1/auth/external/login

POST /api/v1/auth/login is the generic entry that resolves internal vs external and delegates to the right action. Each action looks the user up by email / mlm_username, checks is_active, calls MLM, and on success stashes a pending ticket for that user via MlmTicketStore::setPendingTicket().

MLM ticket lifecycle

App\Services\Identity\MlmTicketStore manages the per-user ticket through cache keys (4-hour TTL):

KeyMeaning
mlm:ticket:pending:{userId}Short-lived bridge set at credential check, before a token exists.
mlm:ticket:session:{userId}:{tokenId}The session-scoped ticket, replayed for that user's MLM calls.
mlm:activity:session:{userId}:{tokenId}Last-activity stamp powering the inactivity timeout.

On session issue, attachPendingTicketToToken() migrates the pending ticket onto the new token id. MlmSessionTicketResolver reads it back (resolveForRead / currentPersonalTicket) — with no service-account fallback: a user's MLM calls always ride their own ticket, so writes can never be silently attributed to a service account.

Background jobs use a separate ticket

Jobs have no logged-in user, so they authenticate through MlmServiceTicket — an OAuth client_credentials ticket (MERIDIANLINK_CLIENT_ID / _SECRET) cached under mlm:service-account:ticket and refreshed under a cache lock so only one worker mints it at a time.

End-to-end login flow

  1. Browser posts credentials to the BFF route (/api/auth/login).
  2. The BFF forwards to Laravel POST /api/v1/auth/login (adding any trusted-device cookie).
  3. Laravel validates against MLM, stores the pending ticket, and decides whether a second factor is required (beginMfaChallenge).
  4. No second factorissueSession() mints the Sanctum token + Portal JWT, attaches the MLM ticket to the token, warms the pipeline, and returns the tokens in X-Api-Token / X-Portal-Jwt headers.
  5. Second factor required → Laravel returns requires_two_factor (or requires_mfa_setup) plus a challenge_token; no session is issued yet.
  6. The BFF extracts any returned tokens into httpOnly cookies and passes the JSON (minus tokens) back to the browser.

issueSession() — what a successful login does

private function issueSession(User $user, IssueApiToken $issueToken, array $extra = []): JsonResponse
{
    $user->forceFill(['last_login_at' => now()])->save();
    $token = $issueToken->handle($user, 'api', ['*']);

    app(MlmTicketStore::class)->attachPendingTicketToToken($user, $token['token_id']);
    app(QueueLoanPipelineWarmup::class)($user, $token['token_id']);
    Cache::put('portal:jwt:token:'.$token['token_id'], $token['portal_jwt_id'], now()->addHours(4));

    $response = $this->message('Authenticated.', [/* user + expiry */ ...$extra]);
    $response->headers->set('X-Api-Token', $token['token']);        // BFF → httpOnly cookie
    $response->headers->set('X-Portal-Jwt', $token['portal_jwt']);  // BFF → httpOnly cookie

    return $response;
}

Two-factor & OTP challenge

When a user has TOTP enabled (hasTwoFactorEnabled()), login returns a challenge_token (5-minute TTL) instead of a session. The client completes it at POST /api/v1/auth/two-factor-challenge with the token + a 6-digit code. AuthController::challenge():

  • Rate-limits to 5 attempts per token (MAX_CHALLENGE_ATTEMPTS); the 6th burns the token and forces a fresh login.
  • Verifies the code — TOTP via VerifyTwoFactorChallenge (TotpService, ±60s window), or an emailed one-time code via OtpService (10-min TTL, sha256-hashed).
  • On success, consumes the token and calls issueSession(). remember_device optionally mints a trusted-device token so the factor is skipped next time.

Users with 2FA not yet set up but required are routed through mfa-setupmfa-setup/confirm, which returns the TOTP secret + otpauth_uri (for the QR code) and then a set of one-time recovery codes.

Managing 2FA for an already-authenticated user:

RouteAction
GET /api/v1/two-factor/statusenabled / pending flags.
POST /api/v1/two-factor/enableBegin enrolment → { secret, otpauth_uri }.
POST /api/v1/two-factor/confirmConfirm a TOTP code → recovery codes (shown once).
DELETE /api/v1/two-factorDisable (re-prompts for password).

The BFF token boundary

The server-side API token never reaches the browser. The Next.js BFF (apps/flex-hub) stores it in httpOnly cookies and proxies every call:

// apps/flex-hub/src/lib/server/session.ts
const SESSION_COOKIE = process.env.SESSION_COOKIE_NAME ?? "lp_session";
const PORTAL_JWT_COOKIE = process.env.PORTAL_JWT_COOKIE_NAME ?? "lp_portal_jwt";

export async function setApiToken(token: string): Promise<void> {
  const store = await cookies();
  store.set(SESSION_COOKIE, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
  });
}

The BFF login route reads the X-Api-Token / X-Portal-Jwt response headers from Laravel and writes them to cookies; the browser's apiMutate / fetch calls hit /api/v1/* on the BFF, which re-attaches the cookies server-side and forwards to Laravel. On the Laravel side, TrustBffProxy middleware verifies the request came from the BFF via a constant-time X-Bff-Proxy-Secret check (security.bff_proxy_secret) before trusting forwarded client IPs.

Never surface the token to client code

Client components must call the BFF (/api/v1/*), never Laravel directly, and must never read the session cookie. The httpOnly attribute is the control that keeps the Sanctum token out of XSS reach — see the web review rules.

Authenticated-route middleware

Protected routes sit behind three middleware, in order:

Route::middleware(['auth:sanctum', 'session.inactivity', 'portal.jwt'])->group(...)
MiddlewareEnforces
auth:sanctumValid, unexpired Sanctum token.
session.inactivityIdle timeout — bumps mlm:activity:* and rejects sessions idle past the window.
portal.jwt (ValidatePortalJwt)X-Portal-Jwt matches the token's linked jti + sub.

auth/refresh rotates both tokens (and invalidates the old MLM ticket + JWT); auth/logout revokes all tokens and clears the MLM ticket cache.

Endpoint reference

RouteVerbAuthPurpose
auth/loginPOSTpublicGeneric login (resolves internal/external).
auth/internal/loginPOSTpublicEmployee login.
auth/external/loginPOSTpublicBroker/correspondent login.
auth/external/select-channelPOSTpublicChannel picker for multi-role external users.
auth/two-factor-challengePOSTpublicComplete a 2FA/OTP challenge.
auth/mfa-setupPOSTpublicBegin required TOTP enrolment at login.
auth/mfa-setup/confirmPOSTpublicConfirm enrolment and issue the session.
auth/meGETsessionCurrent user + roles/permissions.
auth/refreshPOSTsessionRotate the session tokens.
auth/logoutPOSTsessionRevoke tokens; clear MLM ticket.

(All under the /api/v1/ prefix. Password reset and account unlock live under auth/password/* and auth/unlock/*.)

On this page