juanfont/headscale · error

failed to parse oauth access token

Error message

failed to parse oauth access token

What it means

Sentinel in hscontrol/db/oauth.go rejecting a malformed OAuth access token (format hskey-oauthtok-<prefix(12)>-<secret(64)>). Thrown during token parsing before a database lookup, distinguishing 'malformed token' from 'token not found', 'expired', or 'issuing client revoked'.

Source

Thrown at hscontrol/db/oauth.go:42

	// is embedded in the secret so the token endpoint can derive it. The prefix
	// 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
)

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Use a token issued by the OAuth flow (starts with hskey-oauthtok-), not the admin API key
  2. Verify the token survives transport intact (no truncation/whitespace) by decoding it client-side first
  3. If format changed across versions, re-run the OAuth flow to mint fresh tokens
Defensive patterns

Strategy: type-guard

Validate before calling

func isAccessToken(s string) bool {
    rest, ok := strings.CutPrefix(s, "hskey-oauthtok-")
    if !ok {
        return false
    }
    prefix, secret, found := strings.Cut(rest, "-")
    return found && len(prefix) == 12 && len(secret) == 64
}

Type guard

func isAccessToken(s string) bool {
    rest, ok := strings.CutPrefix(s, "hskey-oauthtok-")
    if !ok {
        return false
    }
    prefix, secret, found := strings.Cut(rest, "-")
    return found && len(prefix) == 12 && len(secret) == 64
}

Try / catch

tok, err := db.ValidateAccessToken(bearer)
if err != nil {
    switch {
    case errors.Is(err, db.ErrAccessTokenFailedToParse):
        return unauthorized("malformed access token")
    case errors.Is(err, db.ErrAccessTokenNotFound):
        return unauthorized("unknown token")
    case errors.Is(err, db.ErrAccessTokenExpired):
        return unauthorized("token expired")
    }
    return err
}

Prevention

When it happens

Trigger: Presenting an Authorization: Bearer token that is not an hskey-oauthtok- token — wrong prefix, wrong segment lengths, empty token, or an admin API key (hskey-api-) used against the scoped OAuth API.

Common situations: Client code sending the admin API key to the OAuth-scoped endpoints; token mangled by HTTP headers/proxies (truncation at whitespace); stale token format from before a headscale upgrade.

Understand the failure class

Related errors


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