End-to-end authentication with Symfony, JWT, refresh tokens, and a BFF
A complete authentication flow from a type-safe login form to Symfony Security, rotating refresh tokens, server-side web sessions, protected API requests, logout, recovery, and federated login.
A practical frontend architecture for sharing API contracts, validating responses, fetching during SSR, caching in React, forwarding authentication through a BFF, and keeping mutations synchronized with Symfony.
How to build a durable payment workflow around FlexPay using Orders, Payment Transactions, signed webhooks, idempotent fulfillment, and a migration path from a legacy billing model.
Using named domain exceptions to express rejected business rules, roll back commands, cross Symfony Messenger safely, produce RFC 9457 problem responses, and separate expected failures from real incidents.
Read
Need a software engineer?
Send the goal, stack, and timeline. I'll reply with a next step.
A login endpoint that returns a JWT covers only the first request. The system still needs rules for token storage, renewal, disabled accounts, server-rendered routes, logout, and account recovery.
A complete authentication system must decide where tokens live, how expired access tokens are renewed, how disabled accounts are rejected, how protected routes behave during server rendering, what logout invalidates, and how password recovery avoids leaking whether an account exists.
I faced these questions while building a Symfony API consumed by React applications. The API is stateless, but the browser still needs a secure and convenient session. I did not want long-lived tokens in localStorage, and I did not want every React component to understand JWT expiration.
My solution combines:
Symfony Security for credential verification and account checks.
A short-lived JWT access token.
A persisted, rotating refresh token.
A Backend for Frontend, or BFF, that keeps tokens away from browser JavaScript.
An opaque HttpOnly cookie representing the web session.
Domain policies for account status, password strength, lockout, and recovery challenges.
This guide traces that flow through a generic Symfony API and React application.
A pure single-page application often stores the access token in memory or localStorage and attaches it to API requests.
localStorage is simple, but any JavaScript executing in the origin can read it. A successful cross-site scripting attack can exfiltrate both the access token and a long-lived refresh token.
I use a BFF instead:
Mermaid
The browser stores only an unpredictable session identifier in an HttpOnly cookie. The BFF stores the JWT and refresh token in a server-side session record.
Because the cookie is HttpOnly, browser JavaScript cannot read it. Because the JWT never needs to enter application JavaScript, React components do not become token managers.
This does not remove the need for XSS protection. An attacker can still perform actions through the victim's browser. It does make direct token theft more difficult.
The frontend now has runtime validation and an inferred TypeScript type. If the Symfony response changes unexpectedly, authentication fails at the network boundary instead of creating a partially valid session.
The browser submits an email and password. The BFF receives tokens from Symfony, persists them server-side, and returns only the safe user snapshot to the page.
Passwords are never logged, cached, added to analytics, or copied into the session.
The private key signs tokens. API instances need the public key to verify them. The access token expires after one hour, limiting how long a stolen token remains useful.
Do not include the password hash, security flags intended only for the backend, or unnecessary personal data.
The snapshot improves UI rendering, but it is not an authorization source. It can become stale. Symfony must authorize every protected API request using current backend state.
The cookie contains no JWT and no user data. It is only a lookup key.
For local development, the session store can be an in-memory map. Production deployments with multiple instances or restarts need a shared store such as Redis or a database. Otherwise, a request routed to another instance will appear logged out, and every process restart will delete all sessions.
Rotate the session ID after login and privilege changes to prevent session fixation. Apply a server-side expiry even when the cookie has its own Max-Age.
If the product has separate public and administration applications, use separate cookie names and session namespaces. Sharing a session accidentally can blur privilege and platform boundaries.
Removing an incoming Authorization header is important. The browser must not choose which bearer token the trusted BFF forwards.
The proxy should also forward only deliberate request metadata. Configure trusted proxies correctly before relying on X-Forwarded-For or similar headers for security logging and rate limiting.
The proxy retries the original API request once with the new access token:
TypeScript
let response =awaitforwardWith(session.accessToken);if(response.status ===401){const refreshed =awaitrefreshSession(session);if(refreshed){
response =awaitforwardWith(refreshed.accessToken);}}if(response.status ===401){awaitclearSession();}return response;
Never create an infinite refresh loop. One refresh and one retry are enough.
Rotating refresh tokens introduce a concurrency problem: several requests can receive 401 at the same time and try to consume the same single-use token. Use a per-session single-flight lock so one request refreshes while the others await its result.
For state-changing requests, only retry when you know authentication rejected the request before business code ran. Use idempotency keys for sensitive commands such as payments.
Log identifiers as fingerprints when possible instead of writing raw email addresses into security logs. Never log passwords, JWTs, refresh tokens, reset tokens, or OAuth authorization codes.
Account lockout can itself be abused to deny service to known users. Combine it with IP-aware throttling, monitoring, and a recovery path appropriate for the product's risk level.
Server invalidation handles the persisted refresh token. JWT blocklisting rejects the current access token until its natural expiry. The BFF clears its session and cookie even if the remote invalidation call fails, so the local logout experience is deterministic.
"Logout from all devices" needs more than deleting the current cookie. Index refresh tokens and BFF sessions by user, revoke them all, and consider a user-level token version or credentialsChangedAt claim check.
Password changes and account compromise recovery should normally revoke existing sessions too. Treat this as an explicit security policy rather than an accidental side effect.
Unknown and known email addresses are externally indistinguishable. The endpoint should also be rate-limited and can use human verification at the HTTP boundary.
The database stores only a hash of the challenge token:
The raw token exists only long enough to deliver the link. It is never persisted or logged.
When the user submits a new password, the handler loads the challenge by token hash and expected purpose, verifies its expiry, consumes it, validates the password policy, hashes the password, and persists both changes in one transaction.
Single-use and purpose binding matter. An email-verification token must not be accepted as a password-reset token.
After the provider identity maps to a local user, run the same AccountAccessPolicy used by password authentication. A locked user must not bypass account restrictions by choosing social login.
The callback can issue the same access token, refresh token, and user snapshot as password login.
For a BFF architecture, the safest redirect handoff is a short-lived, one-time authorization code that the BFF exchanges server-to-server. Avoid placing long-lived refresh tokens in query parameters. A URL fragment prevents the token from reaching the HTTP server and referrer, but it is still visible to browser JavaScript and extensions and should be removed immediately after use.
Drive login-attempt and recovery commands through the real command bus with in-memory repositories. Useful scenarios include:
Plain text
Scenario: Hiding whether a password reset account exists
When a password reset is requested for "missing@example.test"
Then the password reset request should be accepted
And no password reset challenge should be announced
Plain text
Scenario: Locking an account after repeated failures
Given an active user exists
When 5 failed logins occur inside 10 minutes
Then the user should be locked
Verify that login persists a server session, the cookie does not contain tokens, one 401 triggers one refresh, a failed refresh clears the session, and route guards preserve a validated return path.
Also test concurrent expired requests. Refresh rotation often works in a single-request test and fails under real browser concurrency.
A refresh token is a long-lived credential. Keep it in a server-side session or a carefully configured HttpOnly cookie, depending on the architecture and threat model.
SameSite=Lax is useful, but evaluate same-site subdomains, browser behavior, and state-changing server functions. Add Origin checks or CSRF tokens where the threat model requires them.
Login, registration, and password-reset messages can create enumeration channels. Keep public errors generic and compare timing behavior where risk justifies it.
Single-use refresh tokens require a per-session refresh lock. Otherwise, parallel requests can consume the same token and log the user out unpredictably.
Redact Authorization, cookies, passwords, refresh tokens, reset links, OAuth codes, and signed state values in application logs and observability tools.
End-to-end authentication is a collaboration between the browser, the web server, Symfony Security, the domain, and persistence.
Zod validates credentials and responses at the frontend boundary.
The BFF keeps access and refresh tokens away from browser storage.
Symfony's login firewall verifies credentials.
A security adapter keeps framework interfaces outside the domain user.
Domain policies decide whether the account can authenticate.
Short-lived access tokens protect API requests.
Single-use refresh tokens rotate the session credential.
The BFF refreshes once and retries transparently.
Symfony remains authoritative for every authorization decision.
Logout invalidates remote credentials and clears the local session.
Security challenges provide expiring, purpose-bound recovery.
Audit records and lockout policies make authentication observable and defensible.
The main decision is not JWT versus cookies. It is which component owns each responsibility. Login, renewal, expiration, revocation, recovery, and authorization must enforce the same account rules without turning client-side state into a security boundary.