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
- Initiate account linking with an HTTP POST (form submit or fetch/XHR) to /api/auth/oidc/{pid}?link=...
- Change the UI to use a form/button performing POST rather than a plain anchor link
- Ensure the POST includes the session cookie (credentials: 'include' for fetch cross-origin)
- 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
- Always render account-linking as a POST form/button, never a plain anchor
- Check DevTools Network tab that the method is POST with cookies attached
- Remember SameSite=Lax: cross-site POSTs won't carry the session cookie by design
- Warn link-preview bots/prefetchers via X-Robots-Tag or rel=nofollow on linking UI
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
- OIDC sign-in failed: state mismatch. Try signing in again.
- You must be signed in to link an external account.
- OIDC sign-in failed: state cookie is missing. Try signing…
- OIDC sign-in failed: invalid state. Try signing in again.
- Unauthorized
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)