{"record":{"id":"5d5c913aab587ae6","repo":"juanfont/headscale","slug":"failed-to-parse-oauth-access-token","errorCode":null,"errorMessage":"failed to parse oauth access token","messagePattern":"failed to parse oauth access token","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/db/oauth.go","lineNumber":42,"sourceCode":"\t// is embedded in the secret so the token endpoint can derive it. The prefix\n\t// itself lives in the types package ([types.OAuthClientPrefix]).\n\toauthClientIDLength     = 12\n\toauthClientSecretLength = 64\n\n\t// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct\n\t// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth\n\t// middleware dispatch a scoped token from an all-access admin key alone.\n\taccessTokenPrefixLength = 12\n\taccessTokenSecretLength = 64\n)\n\nvar (\n\tErrOAuthClientNotFound      = fmt.Errorf(\"oauth client not found: %w\", gorm.ErrRecordNotFound)\n\tErrOAuthClientFailedToParse = errors.New(\"failed to parse oauth client secret\")\n\tErrOAuthClientRevoked       = errors.New(\"oauth client revoked\")\n\n\tErrAccessTokenNotFound      = fmt.Errorf(\"oauth access token not found: %w\", gorm.ErrRecordNotFound)\n\tErrAccessTokenFailedToParse = errors.New(\"failed to parse oauth access token\")\n\tErrAccessTokenExpired       = errors.New(\"oauth access token expired\")\n\tErrAccessTokenClientRevoked = errors.New(\"oauth access token issuing client revoked or deleted\")\n\n\terrSecretHashMalformed = errors.New(\"malformed secret hash\")\n\terrSecretMismatch      = errors.New(\"secret does not match hash\")\n)\n\n// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1\n// lane). They are encoded into every stored hash, so raising them later still\n// verifies credentials stored under the old cost.\nconst (\n\targon2Time    = 2\n\targon2Memory  = 19 * 1024\n\targon2Threads = 1\n\targon2KeyLen  = 32\n\targon2SaltLen = 16\n)\n","sourceCodeStart":24,"sourceCodeEnd":60,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L24-L60","documentation":"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'.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Use a token issued by the OAuth flow (starts with hskey-oauthtok-), not the admin API key","Verify the token survives transport intact (no truncation/whitespace) by decoding it client-side first","If format changed across versions, re-run the OAuth flow to mint fresh tokens"],"exampleFix":null,"handlingStrategy":"type-guard","validationCode":"func isAccessToken(s string) bool {\n    rest, ok := strings.CutPrefix(s, \"hskey-oauthtok-\")\n    if !ok {\n        return false\n    }\n    prefix, secret, found := strings.Cut(rest, \"-\")\n    return found && len(prefix) == 12 && len(secret) == 64\n}","typeGuard":"func isAccessToken(s string) bool {\n    rest, ok := strings.CutPrefix(s, \"hskey-oauthtok-\")\n    if !ok {\n        return false\n    }\n    prefix, secret, found := strings.Cut(rest, \"-\")\n    return found && len(prefix) == 12 && len(secret) == 64\n}","tryCatchPattern":"tok, err := db.ValidateAccessToken(bearer)\nif err != nil {\n    switch {\n    case errors.Is(err, db.ErrAccessTokenFailedToParse):\n        return unauthorized(\"malformed access token\")\n    case errors.Is(err, db.ErrAccessTokenNotFound):\n        return unauthorized(\"unknown token\")\n    case errors.Is(err, db.ErrAccessTokenExpired):\n        return unauthorized(\"token expired\")\n    }\n    return err\n}","preventionTips":["Route hskey-oauthtok- and hskey-api- credentials by prefix before verification","Pass tokens via Authorization headers, never query strings that proxies may truncate","Client-side: validate token shape before sending to fail fast locally"],"tags":["oauth","access-token","authentication","validation","headscale"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}