Dual Authentication: Service JWT plus User Session
The profile service answers two different questions on every request, and it deliberately keeps them apart. One is "is this call coming from a service I trust?" The other is "which human is this call acting on behalf of?" The first is answered by a service token; the second by a session cookie resolved against the Auth service. Neither substitutes for the other — a request can be from a trusted service yet carry no user, and the code treats those as distinct facts rather than folding them into one identity.
The two layers
Layer one — service trust. A Fastify plugin runs before route handlers and inspects the Authorization header for a bearer token. The token is a compact, HMAC-signed token in the JWT shape: a header, a payload, and a signature the receiver recomputes with a shared secret and compares. Verification also enforces the expected signing algorithm, an optional expiry, and an issued-at time, with a small clock-skew tolerance so that clocks that drift a little between services don't reject otherwise-valid tokens. If verification passes, the request is tagged with a service identity (which sibling service made the call) — and, importantly, nothing about the end user is read from this token. The plugin's own comments are explicit that extracting user identity is not its job.
Layer two — user identity. A separate helper reads the session cookie off the incoming request and calls the Auth service's "who am I" endpoint, forwarding the cookie so the Auth service — the one component that owns session truth — decides who the user is. The profile service does not itself decode or trust the session; it asks. What comes back is a small user context: an id, an email, a user type (talent / employer / recruiter / admin), and an optional name. Registrations that haven't yet chosen a user type are filtered out and treated as "no user."
This split is a clean application of single-source-of-truth: session validity lives in the Auth service, and every other service queries it rather than keeping its own copy. It costs an outbound call per authenticated request, but it means a revoked or changed session is honoured everywhere immediately, with no second store to drift.
Role guards
On top of the user context sit role requirements. "Require a logged-in user" raises an unauthorized error when no session resolves; "require a talent / employer / recruiter / admin" additionally raises a forbidden error when the resolved user is the wrong type. Authentication (do we know who you are) and authorization (are you allowed) stay as two separate steps rather than one tangled check.
Opting out: public and public-profile routes
Not every route needs a trusted caller. Health checks, the skills list, and a few lookup endpoints are declared public — service auth is optional there: if a token is present it is still validated and attached, but its absence is not an error. A second category, public-profile reads, covers the GET of an individual talent, employer, or recruiter page: these are viewable without a trusted-service token so that anyone can see a public profile, while the corresponding self (/me) routes are explicitly excluded from that leniency and stay protected. Everything not in those two categories requires a valid service token, and a missing or invalid one is rejected before the handler runs.
Why it's shaped this way
The service sits behind a gateway in a cluster of sibling services, so "the caller is trusted infrastructure" and "the user is such-and-such person" are genuinely independent facts that arrive through different channels — a header set by the calling service, and a cookie set for the browser. Conflating them would force one of two bad outcomes: either trusting user claims baked into a service token (so any service could impersonate any user), or making every public profile view require service-level credentials. Keeping the layers orthogonal lets each route pick exactly the combination it needs.
Inside this node
This node covers the profile service's auth boundary only. Related nodes: the standmeet project's own access model for contrast, and the profile service's talent unlock and quota gate, which is what most of these authenticated routes ultimately protect.