Do Not Decode PHP Sessions Inside a Go Service
A Go service beside PHP should not parse PHP's session format. Keep PHP as the session authority, or move both sides onto a session record you defined.
The first design someone proposes, once a Go process is sitting next to PHP, is to read the session. The cookie is already in the browser. The session lives in files, or Redis, or a sessions table. Go can open the same store. The user stays logged in. The diagram has one box fewer.
That design couples Go to PHP’s session serializer, to the save handler, to whatever the framework stuffed into $_SESSION, and to the moment you rotate the session id. It works in a demo. It fails on the Tuesday you upgrade Symfony, switch Laravel’s session driver, or add a second application server that does not share a local disk.
Authentication is a boundary. Treat it as one before you extract anything that needs to know who the caller is. The rest of putting a Go service next to a PHP monolith — tables, deploys, rollback — gets simpler if this decision is explicit.
What a PHP session actually contains
A session id is an opaque key. The cookie holds the key. The value behind the key is a PHP-serialized payload, unless you replaced the serializer, plus metadata the save handler keeps: last write time, sometimes a user id, sometimes a lock.
PHP’s native serializer is not JSON. Objects become class names. Private properties get nul bytes in their keys. unserialize on data an attacker can influence is a remote code execution story, which is why you do not point a Go parser at a blob a browser can cause PHP to write, and why you do not implement “a small PHP serializer in Go” as a side project. Even a careful port drifts the day someone stores an DateTime or a Doctrine entity in the session because it was convenient in 2016.
Frameworks add their own layout. Symfony’s session bag is not Laravel’s. Both are allowed to change between majors. A Go service that understood last year’s layout is now a production incident with a login-loop symptom and no stack trace on the PHP side, because PHP is fine. PHP is reading the format it wrote.
File sessions are per machine unless you share a filesystem. Database sessions take a lock for the request in many setups. Redis sessions depend on the key prefix, the TTL, and whether you enabled locking. Go that “just reads the key” will ignore the lock and race the PHP request that is mid-update. You will see lost writes that look like flaky tests.
Session fixation and regeneration are part of the contract. After login, PHP should issue a new id. If Go cached the old id, or if a long-lived Go connection authenticated once and kept the user, logout in PHP does not reach it. The user clicked log out. The extracted service still acts as them.
Three designs that survive an upgrade
Leave the session in PHP. The Go service is not on the browser’s cookie path. PHP, or the edge in front of PHP, authenticates the request and calls Go with an internal credential: a short-lived signed token, mTLS, or a network policy that already limits who can call the service. The token carries a user id, a tenant id, an expiry measured in minutes, and nothing you would be sad to see in a log. Go verifies the signature and the expiry. It does not read $_SESSION.
This is the right default for a worker, a report job, a webhook consumer, and most first extractions. If the slice needs the raw session, ask why it was the first slice. The argument in do not extract checkout first applies here in miniature: the path that knows the most about the logged-in human is the path with the most session coupling.
PHP mints a token the browser will send to Go. Use this when the browser must call Go directly, for example a download, a websocket, or an endpoint you are moving with the strangler pattern. PHP remains the place that creates and destroys sessions. On a request it already authenticated, it can set a second cookie, scoped to the Go host, HttpOnly, Secure, SameSite chosen on purpose, value a signed JWT or an opaque id. Prefer the opaque id if you need logout to work immediately: the id points at a row or a Redis key PHP can delete. A JWT lives until it expires unless you add a denylist, and a denylist is a session store with extra steps.
Keep the lifetime short. Five to fifteen minutes, refreshed by PHP while the real session is alive, is easier to reason about than a token that lasts as long as the “remember me” cookie. The Go service rejects expired tokens. It does not refresh them by itself unless you have written down who is allowed to extend a login.
Both sides use a session record you own. You define a table or a Redis hash: id, user id, expiry, revoked-at, maybe a version. PHP’s save handler is a thin adapter over that record, or PHP stops using its native session for the data Go needs and writes this record explicitly. Go reads the same record. Neither side unserializes the other’s objects.
This is the right design when several services must see the same login and you are ready to own that store. It is a migration, not a weekend. Do it before you have three ad hoc parsers. Do not do it by “just pointing Go at the existing sessions table” if that table holds a PHP blob. Add a column you both understand, dual-read, then stop writing the blob into the path Go uses.
What you should not operate: a Go reimplementation of session_decode, a shared /var/lib/php/sessions mount, or a middleware that unserializes session data it fetched from Redis “because the prefix matched”.
Cookies, domains, and logout
If Go is on api.example.com and PHP is on example.com, the existing session cookie might already be sent to both, depending on the Domain attribute. That is not a design. It is an accident of how the cookie was set. Decide:
- Host-only cookies, and an explicit token for Go.
- Or a parent domain, with a written reason, and a review of every subdomain you might add later. A cookie on
.example.comis also sent to any host an attacker can convince you to create.
CSRF tokens that lived in the PHP session do not automatically protect a Go POST. If the browser calls Go with a cookie, Go needs its own CSRF story or it should not accept cookie-authenticated browser mutations at all. A bearer token in a header, minted by PHP, avoids cookie CSRF. It requires the frontend to attach the header, which is a real change, and it is usually the cleaner one.
Logout has to be one function. List the stores a login writes: PHP session, remember-me token, Go opaque token, websocket connections that authenticated at handshake. The logout handler invalidates all of them, or you document the window in which Go will still accept the old token. “The user is logged out” is a claim about that list, not about one session_destroy call.
Password change and “log out everywhere” are the same list. If you miss the Go token, support will hear about it from the person who lost a laptop.
What Go is allowed to believe
The claims in the token or the shared record should be small. User id. Tenant. Expiry. Maybe a session version you can bump to kill old tokens.
Do not put roles in a token that lives for hours if roles change during the day and the extracted service enforces them. Either re-read authorization from a store Go is allowed to query, or keep the privileged action in PHP until you have a real authorization service. A stale admin: true inside a JWT is how a removed administrator keeps a path for the rest of the afternoon.
Do not put personal data in the token beyond what you already put in logs. Tokens end up in access logs, APM, and browser extensions. An email address in the payload will be copied into places your retention policy does not mention.
Verify signatures with a key Go loaded from configuration, rotated on a schedule you have practised. A shared static string in both repositories will leak, and you will be afraid to change it because you do not know which process still has the old one. Two keys valid during rotation, then one, is the boring version. Write it down before the first incident.
How this changes the first extraction
If the candidate service can do its job with a user id minted by PHP, extract it. If it needs the session bag, the flash messages, the half-completed form, and the CSRF token, it is still part of the monolith’s request, and moving it splits a single request into a distributed session problem.
Workers rarely need any of this. They already run as the system, with an explicit id inside the message. Reports often need a user only for the audit log. HTTP fan-out behind PHP can take the internal token. The slices that want the raw cookie are usually the ones you should leave until the session record is a thing you own.
That sequencing is the sort of call a Migration Readiness Audit should make in writing: who authenticates, what Go is allowed to trust, and which slice stays in PHP because the session is the product. The audit is €1,200. Implementation on the PHP to Go migration engagement is at €402/day, and only for a boundary that does not include a homemade PHP session parser.
A logged-in user is a state machine you already operate. A second language does not get to grow a private copy of it.