goauthentik / goauthentik/authentik

Enrollment flow resumes stale FlowPlan across different invitation tokens (itoken silently ignored)

Open
#25,771 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
25.6k
Forks
2k
Avg merge
1d 2h
Merged PRs (30d)
659

Description

## Description

`enrollment-invitation`-style flows (any enrollment flow reached via `?itoken=`) resume a cached, incomplete `FlowPlan` already stored in the browser's session for that flow **slug**, even when the request supplies a *different* `itoken` than the one the cached plan was built from. The invitation token in the query string is silently ignored; the executor continues the stale plan instead of starting a fresh one.

This means: opening a brand-new invitation link in a browser that previously started (but never finished/cancelled) an enrollment flow for the same flow slug returns form data belonging to the **old, unrelated attempt** — not the new invitation.

## To Reproduce

1. Create an enrollment flow with an Invitation stage (e.g. the stock `enrollment-invitation` flow, invitation-based, `designation: enrollment`), followed by a Prompt stage collecting username/name/etc.
2. Create Invitation A (`itoken=A`, e.g. `fixed_data.email: a@example.com`). Open `/if/flow//?itoken=A` in a browser. Get as far as the Prompt stage (form is displayed), but do **not** submit / complete the flow.
3. Without submitting, create a second, unrelated Invitation B (`itoken=B`, `fixed_data.email: b@example.com`).
4. In the **same browser session** (cookies intact from step 2), navigate to `/if/flow//?itoken=B`.
5. Inspect the response from `GET /api/v3/flows/executor//?query=itoken=B`.

**Expected:** the Prompt stage's `initial_value` for `email` (and any other invitation-sourced fields) reflects Invitation B's `fixed_data`.

**Actual:** the response still reflects Invitation A's stale, in-progress plan context — Invitation B's `itoken` is silently ignored. Confirmed via direct inspection of the JSON response body (not a client-side caching artifact — verified server round-trip returned `200` with stale `fields[].initial_value`).

Once the browser's session is invalidated (logout, confirmed via `GET /api/v3/core/users/me/` returning `403`) and the same URL for Invitation B is requested again, the correct data appears immediately. This isolates the cause to server-side session/plan state, not client caching.

## Why this matters beyond a UX glitch

We also observed genuine `PermissionDenied: CSRF Failed: CSRF cookie not set` 500s (via DRF's `SessionAuthentication.enforce_csrf`, `rest_framework/authentication.py:148`) on this same flow, exclusively in sessions that were resuming a stale plan of this kind — never reproducible via a clean/anonymous session (checked over 72h of production logs: zero CSRF failures on any other flow, only 2 total, both on this flow, both during stale-plan-resume testing). Our working theory is that resuming a plan built under different context leaves the session in an inconsistent authenticated/unauthenticated state relative to what the executor expects for CSRF enforcement on the next unsafe-method request, though we haven't fully isolated a minimal repro for the CSRF failure specifically — the plan-resume-across-different-itoken behavior itself is the part we've concretely nailed down and can reproduce reliably.

## Environment

- authentik version: `2026.8.0` (`ghcr.io/goauthentik/server:2026.8.0`)
- Deployment: Docker Compose, Postgres + Redis, behind Traefik (TLS via Cloudflare cert resolver)
- Flow: enrollment-designation flow with Invitation stage → Identification/Password (policy-skipped when invitee email not already registered) → Prompt stage → User Write stage → User Login stage

## Suggested fix direction

The flow planner should key a cached/resumed `FlowPlan` to the specific `itoken` (or more generally, the specific query-string context) it was built from — or simply force a fresh plan whenever the request's query params differ from what produced the cached plan — rather than keying resumption purely off the flow slug.

Happy to provide full JSON request/response captures and server-side log excerpts on request.

Contributor guide

Open the contributing guide

Research direction

Start at GET /api/v3/flows/executor//?query=itoken=B and trace how the flow planner resumes a session-stored FlowPlan; the report also points to rest_framework/authentication.py:148 for the related CSRF path. Reproduce the Invitation A/B sequence in one browser session and verify that changing itoken starts a plan with Invitation B's data rather than stale fields from A.

Written by the indexing model from the issue text.

Assessment

Tech stack
docker, postgresql, python, redis
Domain
api, authentication, backend, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.