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 and

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Have the client obtain a new access token via the OAuth flow (the old one is not renewable)
  2. Check for clock skew: verify NTP on the headscale host and client; a server running fast expires tokens early
  3. 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

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

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/568600ff685528d6. Report an issue: GitHub.