semaphoreui/semaphore · error

Account linking must be initiated with a POST request.

Error message

Account linking must be initiated with a POST request.

What it means

When the OIDC login is initiated in account-linking mode (?link=<provider>), the handler requires the HTTP POST method. This is a CSRF protection: the session cookie is SameSite=Lax, which is attached to cross-site GET navigations, so allowing GET here would let an attacker initiate linking and attach their IdP identity to a victim's account. A GET (or any non-POST) linking request returns HTTP 405 with this message.

Solutions

  1. Initiate account linking with an HTTP POST (form submit or fetch/XHR) to /api/auth/oidc/{pid}?link=...
  2. Change the UI to use a form/button performing POST rather than a plain anchor link
  3. Ensure the POST includes the session cookie (credentials: 'include' for fetch cross-origin)
  4. If this is a prefetch by a bot, it is expected behavior — the request should not be a GET

Example fix

// before
<a href="/api/auth/oidc/github?link=true">Link GitHub</a>
// after
<form method="POST" action="/api/auth/oidc/github?link=true">
  <button type="submit">Link GitHub</button>
</form>
Defensive patterns

Strategy: validation

Validate before calling

// ensure POST before navigating to the link URL:
if linkMode {
    // must use a form/fetch POST, never window.location or <a href>
    fetch(`/api/auth/oidc/${pid}?link=true`, { method: 'POST', credentials: 'include' })
}

Try / catch

const resp = await fetch(`/api/auth/oidc/${pid}?link=true`, { method: 'POST', credentials: 'include' })
if (resp.status === 405) {
    // request was sent as GET (or method overridden) — switch to a real POST form
}

Prevention

When it happens

Trigger: GET /api/auth/oidc/{pid}?link=true (or ?link=anything) with any method other than POST; following a plain link or redirect into the linking endpoint instead of submitting the linking form.

Common situations: Frontend building the link-account URL as an <a href> instead of a POST form/fetch; bookmarking the linking URL and reopening it later; antivirus/link-preview bots prefetching the GET URL; an attempted CSRF attack (which this check exists to block).

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07). Data as JSON: /api/errors/63fd931dedb4bd1b. Report an issue: GitHub.

Appendix: source

Thrown at api/login.go:581

	returnPath := ""
	redirectPath := ""

	config, ok := util.Config.OidcProviders[pid]
	if !ok {
		log.Error(fmt.Errorf("no such provider: %s", pid))
		http.Error(w, "Unknown OIDC provider.", http.StatusNotFound)
		return
	}

	linkMode := r.URL.Query().Get("link") != ""

	if linkMode {
		// POST-only: SameSite=Lax attaches the session cookie to top-level
		// cross-site GET navigations, so a GET here would let an attacker
		// initiate linking (CSRF) and attach their IdP identity to the
		// victim's account. Lax never sends the cookie on cross-site POST.
		if r.Method != http.MethodPost {
			http.Error(w, "Account linking must be initiated with a POST request.", http.StatusMethodNotAllowed)
			return
		}
		session, ok := getSession(r)
		if !ok || !session.IsVerified() {
			http.Error(w, "You must be signed in to link an external account.", http.StatusUnauthorized)
			return
		}
	}

	returnValue := r.URL.Query().Get("return")
	if returnValue != "" {
		if config.ReturnViaState {
			returnPath = returnValue
		} else {
			redirectPath = returnValue
		}
	}

View on GitHub (pinned to 1774ccb71a)