401 Unauthorized from GitLab API after personal access token expiry: distinguishing expiry from scope or revocation failures
0 reputation · 01 Oct 2025, 17:42 UTC
0 reputation · 01 Oct 2025, 17:42 UTC
I want a reliable way to detect that a GitLab personal access token has expired, as opposed to being revoked, mis-scoped, or rejected for another reason, so that an automation job can trigger rotation instead of failing silently. Assume a recent GitLab version (16.x) where PATs require an expiration date and administrators can cap token lifetime.
From what I understand, GitLab returns a generic 401 Unauthorized for expired tokens, the same status used for invalid or revoked tokens, and not every client surfaces a distinct "token expired" signal. That makes root-cause diagnosis ambiguous when a pipeline or integration suddenly starts failing. I am also unsure whether the response body or headers differ between expiry and other auth failures, and whether behavior is consistent between the REST API and Git-over-HTTP.
Does GitLab's API response to an expired PAT contain any indicator (error message, header, or audit event) that distinguishes expiry from revocation or a bad token?
Is there a supported way to query a token's expiry state proactively (for example via the /personal_access_tokens self endpoint) so rotation can happen before jobs start failing?
Does this behavior differ between user PATs and project/group access tokens in current versions?
No — on current GitLab (15.x–16.x) a failed request with an expired personal access token returns the same generic 401 Unauthorized as a revoked or simply wrong token. There is no reliable header or error-body marker you can parse at failure time, and the exact wording varies by version and endpoint, so don't build detection on response text. The supported approach is to check token state before it fails, using the token introspection endpoint, and to treat 401 vs 403 as your only useful failure-time discriminator.
A 401 means the credential itself was not accepted: expired, revoked, malformed, or never valid. A token that is valid but lacks the required scope typically produces 403 Forbidden (or a 404 when GitLab hides the resource from unauthorized users). So the first triage step is cheap:
That narrows your case to expiry vs revocation, which the API response alone cannot settle.
The supported introspection path is the self endpoint, called with the token itself:
curl --header "PRIVATE-TOKEN: $TOKEN" \
https://gitlab.example.com/api/v4/personal_access_tokens/selfA healthy token returns its record, including expires_at, active, revoked, and scopes. Your automation can compute days-to-expiry and trigger rotation N days ahead, which removes the diagnostic problem entirely. Two caveats:
expires_at at creation time (or from a prior introspection) is what resolves that ambiguity.read_api (or api) scope; a minimal-scope token may not be able to introspect itself. Verify against your version.If a job has already failed and you must distinguish expiry from revocation after the fact:
expires_at you recorded (or the account's maximum-lifetime policy — GitLab 16.x requires an expiry on new PATs and admins can cap lifetime, so expiry is statistically the likely culprit for a token that "just stopped working").GET /api/v4/audit_events or Admin → Monitoring → Audit Events) for token revoke/destroy events around the failure window. A revoke event rules expiry out; its absence plus a matching expires_at rules expiry in.Git-over-HTTP with an expired or revoked PAT fails with a generic authentication error from the client — even less signal than the REST API — so the introspection endpoint is your only clean check there too.
Project and group access tokens behave the same for auth failures (generic 401, mandatory expiry in current versions) and support the same introspection pattern. They add one failure mode user PATs don't have: the token becomes inactive if its underlying bot user is removed from the project or group, which can look like an auth failure even before the expiry date.
For the automation job: store expires_at when each token is created, poll /personal_access_tokens/self on a schedule, alert or rotate at a threshold (e.g., 7 days out), and treat any 401 as "rotate now" rather than trying to reverse-engineer the cause from the response. If rotation is impossible and you must diagnose, the audit log plus the recorded expiry date is the dependable path.
Version note: this reflects GitLab 15.x–16.x behavior; token lifetime enforcement and endpoint scope requirements have shifted across releases, so confirm against your exact version — ideally by creating a short-lived test PAT, letting it expire, and comparing its 401 against a manually revoked token's.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.