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:
| Token | Type | Purpose | Transport |
|---|---|---|---|
| API token | Sanctum personal access token | Authenticates the request (auth:sanctum). | X-Api-Token response header → httpOnly cookie |
| Portal JWT | HS256 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) | |
|---|---|---|
| Who | Employees | Broker / correspondent (PML) users |
| Action | AuthenticateInternalUser | AuthenticateExternalUser |
| MLM op | internalAuthTicket(username, password) | externalAuthTicket(username, password, customerCode) |
| Extra | — | customer_code (e.g. PML0368); channel selection for multi-role users |
| Endpoint | POST /api/v1/auth/internal/login | POST /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):
| Key | Meaning |
|---|---|
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
- Browser posts credentials to the BFF route (
/api/auth/login). - The BFF forwards to Laravel
POST /api/v1/auth/login(adding any trusted-device cookie). - Laravel validates against MLM, stores the pending ticket, and decides whether a second factor is required (
beginMfaChallenge). - No second factor →
issueSession()mints the Sanctum token + Portal JWT, attaches the MLM ticket to the token, warms the pipeline, and returns the tokens inX-Api-Token/X-Portal-Jwtheaders. - Second factor required → Laravel returns
requires_two_factor(orrequires_mfa_setup) plus achallenge_token; no session is issued yet. - 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 viaOtpService(10-min TTL, sha256-hashed). - On success, consumes the token and calls
issueSession().remember_deviceoptionally 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-setup → mfa-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:
| Route | Action |
|---|---|
GET /api/v1/two-factor/status | enabled / pending flags. |
POST /api/v1/two-factor/enable | Begin enrolment → { secret, otpauth_uri }. |
POST /api/v1/two-factor/confirm | Confirm a TOTP code → recovery codes (shown once). |
DELETE /api/v1/two-factor | Disable (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(...)| Middleware | Enforces |
|---|---|
auth:sanctum | Valid, unexpired Sanctum token. |
session.inactivity | Idle 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
| Route | Verb | Auth | Purpose |
|---|---|---|---|
auth/login | POST | public | Generic login (resolves internal/external). |
auth/internal/login | POST | public | Employee login. |
auth/external/login | POST | public | Broker/correspondent login. |
auth/external/select-channel | POST | public | Channel picker for multi-role external users. |
auth/two-factor-challenge | POST | public | Complete a 2FA/OTP challenge. |
auth/mfa-setup | POST | public | Begin required TOTP enrolment at login. |
auth/mfa-setup/confirm | POST | public | Confirm enrolment and issue the session. |
auth/me | GET | session | Current user + roles/permissions. |
auth/refresh | POST | session | Rotate the session tokens. |
auth/logout | POST | session | Revoke tokens; clear MLM ticket. |
(All under the /api/v1/ prefix. Password reset and account unlock live under
auth/password/* and auth/unlock/*.)
Related
- Authorization & Visibility — what an authenticated user is allowed to do and see.
- Artisan Commands — identity sync + audit commands.
Artisan Commands
Reference for every custom artisan command across the FlexPoint Portal API and the FlexRate pricing engine, with flags and when to run them.
Authorization & Visibility
The two independent gates every request passes — permission (what you can do) and data-scoped visibility (which records you can see) — and why even admins hold no visibility bypass.