{"record":{"id":"d168df33ee4b9861","repo":"juanfont/headscale","slug":"invalid-oauth-access-token-w","errorCode":null,"errorMessage":"invalid oauth access token: %w","messagePattern":"invalid oauth access token: %w","errorType":"validation","errorClass":null,"httpStatus":401,"severity":"error","filePath":"hscontrol/db/oauth.go","lineNumber":353,"sourceCode":"\t}\n\n\tprefix, secret, err := parsePrefixedKey(\n\t\trest,\n\t\taccessTokenPrefixLength,\n\t\taccessTokenSecretLength,\n\t\tErrAccessTokenFailedToParse,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar token types.OAuthAccessToken\n\tif err := hsdb.DB.First(&token, \"prefix = ?\", prefix).Error; err != nil { //nolint:noinlineerr\n\t\treturn nil, ErrAccessTokenNotFound\n\t}\n\n\tif err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr\n\t\treturn nil, fmt.Errorf(\"invalid oauth access token: %w\", err)\n\t}\n\n\tif token.Expiration != nil && token.Expiration.Before(time.Now()) {\n\t\treturn nil, ErrAccessTokenExpired\n\t}\n\n\t// Bind validity to the issuing client: a token whose client has been\n\t// revoked or deleted is rejected. This closes a mint/revoke race (where a\n\t// token could be inserted after the client's tokens were purged) and any\n\t// orphan left by manual deletion or a future soft-revoke path.\n\tvar client types.OAuthClient\n\tif err := hsdb.DB.First(&client, \"client_id = ?\", token.ClientID).Error; err != nil { //nolint:noinlineerr\n\t\treturn nil, ErrAccessTokenClientRevoked\n\t}\n\n\tif client.Revoked != nil {\n\t\treturn nil, ErrAccessTokenClientRevoked\n\t}","sourceCodeStart":335,"sourceCodeEnd":371,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L335-L371","documentation":"AuthenticateAccessToken found a token row by prefix but verifySecret rejected the presented secret half: either errSecretMismatch (wrong token — hash comparison failed) or errSecretHashMalformed (stored Hash column is not a valid PHC string). Expiry and client-binding checks come after, so this error isolates the credential check itself.","triggerScenarios":"Bearer token from a different installation or a rotated-away token; token string truncated or with stray characters; Hash column corrupted.","commonSituations":"Environments copied between prod/staging sharing prefix configuration; secrets mangled by YAML/env serialization (line breaks in the base64).","solutions":["Mint a fresh access token and replace the stored credential","Verify the stored Hash looks like a valid $argon2id$ PHC string with intact base64","Ensure the token is passed whole in the Authorization header"],"exampleFix":"// before\nif err != nil {\n\treturn err\n}\n\n// after\nswitch {\ncase errors.Is(err, db.ErrAccessTokenNotFound):\n\treturn errUnauthorized(\"unknown token\")\ncase err != nil: // includes invalid-token and expiry\n\treturn errUnauthorized(\"invalid or expired token\")\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"func isTokenRejected(err error) bool {\n\treturn errors.Is(err, db.ErrAccessTokenNotFound) ||\n\t\terrors.Is(err, db.ErrAccessTokenExpired) ||\n\t\terrors.Is(err, db.ErrAccessTokenClientRevoked) ||\n\t\terr != nil && strings.Contains(err.Error(), \"invalid oauth access token\")\n}","tryCatchPattern":"if _, err := hsdb.AuthenticateAccessToken(bearer); err != nil {\n\tif isTokenRejected(err) {\n\t\treturn errUnauthorized\n\t}\n\treturn err // genuine 500 (DB issue)\n}","preventionTips":["Unwrap errSecretMismatch vs errSecretHashMalformed to tell wrong token from corrupted row","Validate token shape (prefix + length) before hitting the DB","Keep bearer tokens out of logs and shell history"],"tags":["go","oauth","access-token","authentication","argon2"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}