{"record":{"id":"568600ff685528d6","repo":"juanfont/headscale","slug":"oauth-access-token-expired","errorCode":null,"errorMessage":"oauth access token expired","messagePattern":"oauth access token expired","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"hscontrol/db/oauth.go","lineNumber":43,"sourceCode":"\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\n// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and","sourceCodeStart":25,"sourceCodeEnd":61,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L25-L61","documentation":"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).","triggerScenarios":"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.","commonSituations":"Automation that stores tokens indefinitely without a refresh path; server clock drift; tokens created with a short expiry for testing.","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"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"if err := db.ValidateAccessToken(token); err != nil {\n    if errors.Is(err, db.ErrAccessTokenExpired) {\n        // client refresh path: mint a new token, do not retry the old one\n        return unauthorizedWithHint(\"token expired; re-authenticate\")\n    }\n    return err\n}","preventionTips":["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"],"tags":["oauth","access-token","expiration","authentication","headscale"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}