juanfont/headscale · warning
oauth access token expired
Error message
oauth access token expired
What it means
Sentinel in hscontrol/db/oauth.go returned when validating an OAuth access token whose expiration time is in the past. Parsing and lookup succeeded; the token is well-formed and known, but expired. Sibling errors distinguish revoked tokens (ErrAccessTokenClientRevoked) and unknown tokens (ErrAccessTokenNotFound).
Source
Thrown at hscontrol/db/oauth.go:43
// itself lives in the types package ([types.OAuthClientPrefix]).
oauthClientIDLength = 12
oauthClientSecretLength = 64
// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct
// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth
// middleware dispatch a scoped token from an all-access admin key alone.
accessTokenPrefixLength = 12
accessTokenSecretLength = 64
)
var (
ErrOAuthClientNotFound = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound)
ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret")
ErrOAuthClientRevoked = errors.New("oauth client revoked")
ErrAccessTokenNotFound = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound)
ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
ErrAccessTokenExpired = errors.New("oauth access token expired")
ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")
errSecretHashMalformed = errors.New("malformed secret hash")
errSecretMismatch = errors.New("secret does not match hash")
)
// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
// lane). They are encoded into every stored hash, so raising them later still
// verifies credentials stored under the old cost.
const (
argon2Time = 2
argon2Memory = 19 * 1024
argon2Threads = 1
argon2KeyLen = 32
argon2SaltLen = 16
)
// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB andView on GitHub (pinned to 565fd254d0)
Solutions
- Have the client obtain a new access token via the OAuth flow (the old one is not renewable)
- Check for clock skew: verify NTP on the headscale host and client; a server running fast expires tokens early
- When minting tokens, choose an expiration suited to the client's refresh cadence
Defensive patterns
Strategy: try-catch
Try / catch
if err := db.ValidateAccessToken(token); err != nil {
if errors.Is(err, db.ErrAccessTokenExpired) {
// client refresh path: mint a new token, do not retry the old one
return unauthorizedWithHint("token expired; re-authenticate")
}
return err
} Prevention
- Store expiry alongside tokens and refresh proactively (e.g. at 80% of lifetime)
- Keep NTP working on servers issuing/validating tokens
- Log token ID (not the token) on expiry to correlate refresh storms
When it happens
Trigger: Calling token verification with a token whose expiry timestamp passed — long-lived clients caching tokens beyond their lifetime, or clock skew between the client and the headscale server making a valid token appear expired.
Common situations: Automation that stores tokens indefinitely without a refresh path; server clock drift; tokens created with a short expiry for testing.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- failed to parse oauth access token
- failed to parse oauth client secret
- ErrAccessTokenNotFound
- invalid oauth access token: %w
- failed to parse ApiKey
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/568600ff685528d6.
Report an issue: GitHub.