[{"data":1,"prerenderedAt":3721},["ShallowReactive",2],{"page-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fimplementing-oauth2-authorization-code-flow-in-fastapi\u002F":3,"faq-schema-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fimplementing-oauth2-authorization-code-flow-in-fastapi\u002F":3700},{"id":4,"title":5,"body":6,"description":3690,"extension":3691,"meta":3692,"navigation":304,"path":3696,"seo":3697,"stem":3698,"__hash__":3699},"content\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fimplementing-oauth2-authorization-code-flow-in-fastapi\u002Findex.md","Implementing the OAuth2 Authorization Code Flow in FastAPI",{"type":7,"value":8,"toc":3679},"minimark",[9,13,28,41,44,49,60,226,233,867,874,1430,1445,1447,1451,1454,1581,1589,2085,2095,2097,2101,2109,2206,2213,2834,2846,2848,2852,2865,2967,3459,3467,3469,3473,3476,3543,3546,3553,3555,3559,3562,3585,3587,3591,3598,3602,3609,3619,3625,3631,3637,3641,3675],[10,11,5],"h1",{"id":12},"implementing-the-oauth2-authorization-code-flow-in-fastapi",[14,15,16,17,21,22,27],"p",{},"Almost every commercial API product eventually has to act as an OAuth2 ",[18,19,20],"em",{},"client",": your service connects to your customer's Google Workspace, HubSpot, Shopify or GitHub account and works on their behalf. That is a different job from issuing credentials for your own API, which is covered in the parent ",[23,24,26],"a",{"href":25},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F","Handling API Authentication in Python"," guide. This page resolves one specific decision — which OAuth2 flow to implement and how to hold the resulting tokens — and gives you the FastAPI routes, the storage schema, the refresh logic and the background sweep that keeps revoked grants from silently rotting your product.",[14,29,30,31,35,36,40],{},"The short answer: use the authorization code flow with PKCE, always, even when the provider says PKCE is optional and even though you have a confidential server-side client. It costs you fifteen lines and it removes an entire class of interception bug from your incident log. Everything below assumes an async-native ",[23,32,34],{"href":33},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","FastAPI setup"," and ",[23,37,39],{"href":38},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fhttpx-vs-requests-for-async\u002F","httpx as your async HTTP client",".",[42,43],"hr",{},[45,46,48],"h2",{"id":47},"the-flow-end-to-end","The flow, end to end",[14,50,51,52,56,57,59],{},"Four parties move through eight steps. Your API never sees the customer's password; the provider never sees your client secret in a browser. The two pieces of state you generate before the redirect — a random ",[53,54,55],"code",{},"state"," value and a PKCE code verifier — are what make the round trip safe. The ",[53,58,55],{}," proves the callback belongs to a session you started, defeating cross-site request forgery on the callback route. The verifier proves the process redeeming the code is the same process that requested it, defeating code interception through a malicious app or a leaky referrer.",[61,62,70,71,70,75,70,79,70,86,70,105,70,114,70,122,70,127,70,131,70,135,70,139,70,144,70,148,70,154,70,156,70,158,70,160,70,166,70,171,70,175,70,179,70,184,70,188,70,192,70,194,70,198,70,201,70,206,70,209,70,213,70,217,70,222],"svg",{"viewBox":63,"role":64,"ariaLabelledBy":65,"xmlns":68,"style":69},"0 0 760 420","img",[66,67],"oa2f-t","oa2f-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[72,73,74],"title",{"id":66},"Authorization code flow with PKCE, step by step",[76,77,78],"desc",{"id":67},"Sequence diagram across four lanes — user browser, your FastAPI service, the provider authorization server, and your token store — showing the redirect with a code challenge and state, the consent step, the callback carrying a code, the token exchange with the verifier, and the encrypted write of access and refresh tokens.",[80,81],"rect",{"x":82,"y":82,"width":83,"height":84,"fill":85},"0","760","420","var(--c-surface)",[87,88,89,90,70],"defs",{},"\n    ",[91,92,99,100,89],"marker",{"id":93,"viewBox":94,"refX":95,"refY":96,"markerWidth":97,"markerHeight":97,"orient":98},"oa2f-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[101,102],"path",{"d":103,"fill":104},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[80,106],{"x":107,"y":108,"width":109,"height":110,"rx":111,"fill":85,"stroke":112,"style":113},"20","15","150","34","8","var(--c-border)","stroke-width:1.5;",[115,116,121],"text",{"x":117,"y":118,"fill":119,"style":120},"95","37","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);","User browser",[80,123],{"x":124,"y":108,"width":109,"height":110,"rx":111,"fill":85,"stroke":125,"style":126},"210","var(--c-blue)","stroke-width:2;",[115,128,130],{"x":129,"y":118,"fill":119,"style":120},"285","Your FastAPI",[80,132],{"x":133,"y":108,"width":109,"height":110,"rx":111,"fill":85,"stroke":134,"style":126},"415","var(--c-teal)",[115,136,138],{"x":137,"y":118,"fill":119,"style":120},"490","Provider auth",[80,140],{"x":141,"y":108,"width":142,"height":110,"rx":111,"fill":85,"stroke":143,"style":126},"605","135","var(--c-coral)",[115,145,147],{"x":146,"y":118,"fill":119,"style":120},"672","Token store",[149,150],"line",{"x1":117,"y1":151,"x2":117,"y2":152,"stroke":112,"style":153},"52","400","stroke-width:1;stroke-dasharray:4 4;",[149,155],{"x1":129,"y1":151,"x2":129,"y2":152,"stroke":112,"style":153},[149,157],{"x1":137,"y1":151,"x2":137,"y2":152,"stroke":112,"style":153},[149,159],{"x1":146,"y1":151,"x2":146,"y2":152,"stroke":112,"style":153},[115,161,165],{"x":162,"y":163,"fill":119,"style":164},"190","82","text-anchor:middle;font-size:11;font-family:var(--font-sans);","GET \u002Foauth\u002Fconnect",[149,167],{"x1":117,"y1":168,"x2":169,"y2":168,"stroke":104,"style":170},"90","281","stroke-width:2;marker-end:url(#oa2f-arrow);",[115,172,174],{"x":162,"y":173,"fill":119,"style":164},"122","302 + challenge + state",[149,176],{"x1":129,"y1":177,"x2":178,"y2":177,"stroke":104,"style":170},"130","99",[115,180,183],{"x":181,"y":182,"fill":119,"style":164},"292","162","login + consent screen",[149,185],{"x1":117,"y1":186,"x2":187,"y2":186,"stroke":134,"style":170},"170","486",[115,189,191],{"x":181,"y":190,"fill":119,"style":164},"202","302 back with code + state",[149,193],{"x1":137,"y1":124,"x2":178,"y2":124,"stroke":134,"style":170},[115,195,197],{"x":162,"y":196,"fill":119,"style":164},"242","GET \u002Foauth\u002Fcallback",[149,199],{"x1":117,"y1":200,"x2":169,"y2":200,"stroke":104,"style":170},"250",[115,202,205],{"x":203,"y":204,"fill":119,"style":164},"387","282","POST \u002Ftoken + verifier",[149,207],{"x1":129,"y1":208,"x2":187,"y2":208,"stroke":125,"style":170},"290",[115,210,212],{"x":203,"y":211,"fill":119,"style":164},"322","access + refresh tokens",[149,214],{"x1":137,"y1":215,"x2":216,"y2":215,"stroke":125,"style":170},"330","289",[115,218,221],{"x":219,"y":220,"fill":119,"style":164},"478","362","encrypt + store grant",[149,223],{"x1":129,"y1":224,"x2":225,"y2":224,"stroke":143,"style":170},"370","668",[14,227,228,229,232],{},"Here are the two routes. They depend on Starlette's ",[53,230,231],{},"SessionMiddleware"," for the short-lived verifier and state — a signed cookie is the cheapest correct place to hold them, because the values live for about ninety seconds and never need to survive a deploy.",[234,235,240],"pre",{"className":236,"code":237,"language":238,"meta":239,"style":239},"language-python shiki shiki-themes github-light github-dark","# app\u002Foauth_client.py\nimport base64\nimport hashlib\nimport os\nimport secrets\nfrom urllib.parse import urlencode\n\nimport httpx\nfrom fastapi import APIRouter, HTTPException, Request\nfrom fastapi.responses import RedirectResponse\n\nrouter = APIRouter(prefix=\"\u002Foauth\", tags=[\"oauth\"])\n\nAUTHORIZE_URL = os.getenv(\"PROVIDER_AUTHORIZE_URL\", \"\")\nTOKEN_URL = os.getenv(\"PROVIDER_TOKEN_URL\", \"\")\nCLIENT_ID = os.getenv(\"PROVIDER_CLIENT_ID\", \"\")\nCLIENT_SECRET = os.getenv(\"PROVIDER_CLIENT_SECRET\", \"\")\nREDIRECT_URI = os.getenv(\"PROVIDER_REDIRECT_URI\", \"\")\nSCOPES = os.getenv(\"PROVIDER_SCOPES\", \"\")\nPOST_CONNECT_URL = os.getenv(\"APP_POST_CONNECT_URL\", \"\u002F\")\nHTTP_TIMEOUT = float(os.getenv(\"PROVIDER_HTTP_TIMEOUT\", \"10\"))\n\n\ndef _b64url(raw: bytes) -> str:\n    return base64.urlsafe_b64encode(raw).rstrip(b\"=\").decode()\n\n\n@router.get(\"\u002Fconnect\")\nasync def connect(request: Request) -> RedirectResponse:\n    verifier = _b64url(secrets.token_bytes(64))\n    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())\n    state = secrets.token_urlsafe(32)\n    request.session[\"pkce_verifier\"] = verifier\n    request.session[\"oauth_state\"] = state\n\n    params = {\n        \"response_type\": \"code\",\n        \"client_id\": CLIENT_ID,\n        \"redirect_uri\": REDIRECT_URI,\n        \"scope\": SCOPES,\n        \"state\": state,\n        \"code_challenge\": challenge,\n        \"code_challenge_method\": \"S256\",\n    }\n    return RedirectResponse(f\"{AUTHORIZE_URL}?{urlencode(params)}\", status_code=302)\n","python","",[53,241,242,250,261,269,277,285,299,306,314,327,340,345,384,389,413,432,451,470,489,508,528,553,558,563,588,606,611,616,630,645,661,672,688,705,720,725,736,751,763,775,787,796,805,818,824],{"__ignoreMap":239},[243,244,246],"span",{"class":149,"line":245},1,[243,247,249],{"class":248},"sJ8bj","# app\u002Foauth_client.py\n",[243,251,253,257],{"class":149,"line":252},2,[243,254,256],{"class":255},"szBVR","import",[243,258,260],{"class":259},"sVt8B"," base64\n",[243,262,264,266],{"class":149,"line":263},3,[243,265,256],{"class":255},[243,267,268],{"class":259}," hashlib\n",[243,270,272,274],{"class":149,"line":271},4,[243,273,256],{"class":255},[243,275,276],{"class":259}," os\n",[243,278,280,282],{"class":149,"line":279},5,[243,281,256],{"class":255},[243,283,284],{"class":259}," secrets\n",[243,286,288,291,294,296],{"class":149,"line":287},6,[243,289,290],{"class":255},"from",[243,292,293],{"class":259}," urllib.parse ",[243,295,256],{"class":255},[243,297,298],{"class":259}," urlencode\n",[243,300,302],{"class":149,"line":301},7,[243,303,305],{"emptyLinePlaceholder":304},true,"\n",[243,307,309,311],{"class":149,"line":308},8,[243,310,256],{"class":255},[243,312,313],{"class":259}," httpx\n",[243,315,317,319,322,324],{"class":149,"line":316},9,[243,318,290],{"class":255},[243,320,321],{"class":259}," fastapi ",[243,323,256],{"class":255},[243,325,326],{"class":259}," APIRouter, HTTPException, Request\n",[243,328,330,332,335,337],{"class":149,"line":329},10,[243,331,290],{"class":255},[243,333,334],{"class":259}," fastapi.responses ",[243,336,256],{"class":255},[243,338,339],{"class":259}," RedirectResponse\n",[243,341,343],{"class":149,"line":342},11,[243,344,305],{"emptyLinePlaceholder":304},[243,346,348,351,354,357,361,363,367,370,373,375,378,381],{"class":149,"line":347},12,[243,349,350],{"class":259},"router ",[243,352,353],{"class":255},"=",[243,355,356],{"class":259}," APIRouter(",[243,358,360],{"class":359},"s4XuR","prefix",[243,362,353],{"class":255},[243,364,366],{"class":365},"sZZnC","\"\u002Foauth\"",[243,368,369],{"class":259},", ",[243,371,372],{"class":359},"tags",[243,374,353],{"class":255},[243,376,377],{"class":259},"[",[243,379,380],{"class":365},"\"oauth\"",[243,382,383],{"class":259},"])\n",[243,385,387],{"class":149,"line":386},13,[243,388,305],{"emptyLinePlaceholder":304},[243,390,392,396,399,402,405,407,410],{"class":149,"line":391},14,[243,393,395],{"class":394},"sj4cs","AUTHORIZE_URL",[243,397,398],{"class":255}," =",[243,400,401],{"class":259}," os.getenv(",[243,403,404],{"class":365},"\"PROVIDER_AUTHORIZE_URL\"",[243,406,369],{"class":259},[243,408,409],{"class":365},"\"\"",[243,411,412],{"class":259},")\n",[243,414,416,419,421,423,426,428,430],{"class":149,"line":415},15,[243,417,418],{"class":394},"TOKEN_URL",[243,420,398],{"class":255},[243,422,401],{"class":259},[243,424,425],{"class":365},"\"PROVIDER_TOKEN_URL\"",[243,427,369],{"class":259},[243,429,409],{"class":365},[243,431,412],{"class":259},[243,433,435,438,440,442,445,447,449],{"class":149,"line":434},16,[243,436,437],{"class":394},"CLIENT_ID",[243,439,398],{"class":255},[243,441,401],{"class":259},[243,443,444],{"class":365},"\"PROVIDER_CLIENT_ID\"",[243,446,369],{"class":259},[243,448,409],{"class":365},[243,450,412],{"class":259},[243,452,454,457,459,461,464,466,468],{"class":149,"line":453},17,[243,455,456],{"class":394},"CLIENT_SECRET",[243,458,398],{"class":255},[243,460,401],{"class":259},[243,462,463],{"class":365},"\"PROVIDER_CLIENT_SECRET\"",[243,465,369],{"class":259},[243,467,409],{"class":365},[243,469,412],{"class":259},[243,471,473,476,478,480,483,485,487],{"class":149,"line":472},18,[243,474,475],{"class":394},"REDIRECT_URI",[243,477,398],{"class":255},[243,479,401],{"class":259},[243,481,482],{"class":365},"\"PROVIDER_REDIRECT_URI\"",[243,484,369],{"class":259},[243,486,409],{"class":365},[243,488,412],{"class":259},[243,490,492,495,497,499,502,504,506],{"class":149,"line":491},19,[243,493,494],{"class":394},"SCOPES",[243,496,398],{"class":255},[243,498,401],{"class":259},[243,500,501],{"class":365},"\"PROVIDER_SCOPES\"",[243,503,369],{"class":259},[243,505,409],{"class":365},[243,507,412],{"class":259},[243,509,511,514,516,518,521,523,526],{"class":149,"line":510},20,[243,512,513],{"class":394},"POST_CONNECT_URL",[243,515,398],{"class":255},[243,517,401],{"class":259},[243,519,520],{"class":365},"\"APP_POST_CONNECT_URL\"",[243,522,369],{"class":259},[243,524,525],{"class":365},"\"\u002F\"",[243,527,412],{"class":259},[243,529,531,534,536,539,542,545,547,550],{"class":149,"line":530},21,[243,532,533],{"class":394},"HTTP_TIMEOUT",[243,535,398],{"class":255},[243,537,538],{"class":394}," float",[243,540,541],{"class":259},"(os.getenv(",[243,543,544],{"class":365},"\"PROVIDER_HTTP_TIMEOUT\"",[243,546,369],{"class":259},[243,548,549],{"class":365},"\"10\"",[243,551,552],{"class":259},"))\n",[243,554,556],{"class":149,"line":555},22,[243,557,305],{"emptyLinePlaceholder":304},[243,559,561],{"class":149,"line":560},23,[243,562,305],{"emptyLinePlaceholder":304},[243,564,566,569,573,576,579,582,585],{"class":149,"line":565},24,[243,567,568],{"class":255},"def",[243,570,572],{"class":571},"sScJk"," _b64url",[243,574,575],{"class":259},"(raw: ",[243,577,578],{"class":394},"bytes",[243,580,581],{"class":259},") -> ",[243,583,584],{"class":394},"str",[243,586,587],{"class":259},":\n",[243,589,591,594,597,600,603],{"class":149,"line":590},25,[243,592,593],{"class":255},"    return",[243,595,596],{"class":259}," base64.urlsafe_b64encode(raw).rstrip(",[243,598,599],{"class":255},"b",[243,601,602],{"class":365},"\"=\"",[243,604,605],{"class":259},").decode()\n",[243,607,609],{"class":149,"line":608},26,[243,610,305],{"emptyLinePlaceholder":304},[243,612,614],{"class":149,"line":613},27,[243,615,305],{"emptyLinePlaceholder":304},[243,617,619,622,625,628],{"class":149,"line":618},28,[243,620,621],{"class":571},"@router.get",[243,623,624],{"class":259},"(",[243,626,627],{"class":365},"\"\u002Fconnect\"",[243,629,412],{"class":259},[243,631,633,636,639,642],{"class":149,"line":632},29,[243,634,635],{"class":255},"async",[243,637,638],{"class":255}," def",[243,640,641],{"class":571}," connect",[243,643,644],{"class":259},"(request: Request) -> RedirectResponse:\n",[243,646,648,651,653,656,659],{"class":149,"line":647},30,[243,649,650],{"class":259},"    verifier ",[243,652,353],{"class":255},[243,654,655],{"class":259}," _b64url(secrets.token_bytes(",[243,657,658],{"class":394},"64",[243,660,552],{"class":259},[243,662,664,667,669],{"class":149,"line":663},31,[243,665,666],{"class":259},"    challenge ",[243,668,353],{"class":255},[243,670,671],{"class":259}," _b64url(hashlib.sha256(verifier.encode()).digest())\n",[243,673,675,678,680,683,686],{"class":149,"line":674},32,[243,676,677],{"class":259},"    state ",[243,679,353],{"class":255},[243,681,682],{"class":259}," secrets.token_urlsafe(",[243,684,685],{"class":394},"32",[243,687,412],{"class":259},[243,689,691,694,697,700,702],{"class":149,"line":690},33,[243,692,693],{"class":259},"    request.session[",[243,695,696],{"class":365},"\"pkce_verifier\"",[243,698,699],{"class":259},"] ",[243,701,353],{"class":255},[243,703,704],{"class":259}," verifier\n",[243,706,708,710,713,715,717],{"class":149,"line":707},34,[243,709,693],{"class":259},[243,711,712],{"class":365},"\"oauth_state\"",[243,714,699],{"class":259},[243,716,353],{"class":255},[243,718,719],{"class":259}," state\n",[243,721,723],{"class":149,"line":722},35,[243,724,305],{"emptyLinePlaceholder":304},[243,726,728,731,733],{"class":149,"line":727},36,[243,729,730],{"class":259},"    params ",[243,732,353],{"class":255},[243,734,735],{"class":259}," {\n",[243,737,739,742,745,748],{"class":149,"line":738},37,[243,740,741],{"class":365},"        \"response_type\"",[243,743,744],{"class":259},": ",[243,746,747],{"class":365},"\"code\"",[243,749,750],{"class":259},",\n",[243,752,754,757,759,761],{"class":149,"line":753},38,[243,755,756],{"class":365},"        \"client_id\"",[243,758,744],{"class":259},[243,760,437],{"class":394},[243,762,750],{"class":259},[243,764,766,769,771,773],{"class":149,"line":765},39,[243,767,768],{"class":365},"        \"redirect_uri\"",[243,770,744],{"class":259},[243,772,475],{"class":394},[243,774,750],{"class":259},[243,776,778,781,783,785],{"class":149,"line":777},40,[243,779,780],{"class":365},"        \"scope\"",[243,782,744],{"class":259},[243,784,494],{"class":394},[243,786,750],{"class":259},[243,788,790,793],{"class":149,"line":789},41,[243,791,792],{"class":365},"        \"state\"",[243,794,795],{"class":259},": state,\n",[243,797,799,802],{"class":149,"line":798},42,[243,800,801],{"class":365},"        \"code_challenge\"",[243,803,804],{"class":259},": challenge,\n",[243,806,808,811,813,816],{"class":149,"line":807},43,[243,809,810],{"class":365},"        \"code_challenge_method\"",[243,812,744],{"class":259},[243,814,815],{"class":365},"\"S256\"",[243,817,750],{"class":259},[243,819,821],{"class":149,"line":820},44,[243,822,823],{"class":259},"    }\n",[243,825,827,829,832,835,838,841,844,847,850,853,855,857,860,862,865],{"class":149,"line":826},45,[243,828,593],{"class":255},[243,830,831],{"class":259}," RedirectResponse(",[243,833,834],{"class":255},"f",[243,836,837],{"class":365},"\"",[243,839,840],{"class":394},"{AUTHORIZE_URL}",[243,842,843],{"class":365},"?",[243,845,846],{"class":394},"{",[243,848,849],{"class":259},"urlencode(params)",[243,851,852],{"class":394},"}",[243,854,837],{"class":365},[243,856,369],{"class":259},[243,858,859],{"class":359},"status_code",[243,861,353],{"class":255},[243,863,864],{"class":394},"302",[243,866,412],{"class":259},[14,868,869,870,873],{},"The callback route does three things in order: reject provider-side errors, verify state with a constant-time comparison, then redeem the code. Never skip the constant-time compare — ",[53,871,872],{},"=="," on a secret leaks timing, and this is a two-word fix.",[234,875,877],{"className":236,"code":876,"language":238,"meta":239,"style":239},"@router.get(\"\u002Fcallback\")\nasync def callback(\n    request: Request,\n    code: str | None = None,\n    state: str | None = None,\n    error: str | None = None,\n) -> RedirectResponse:\n    match error:\n        case None:\n            pass\n        case \"access_denied\":\n            raise HTTPException(status_code=400, detail=\"Connection declined by user\")\n        case _:\n            raise HTTPException(status_code=400, detail=f\"Provider error: {error}\")\n\n    expected = request.session.pop(\"oauth_state\", None)\n    verifier = request.session.pop(\"pkce_verifier\", None)\n    if not (code and state and expected and verifier):\n        raise HTTPException(status_code=400, detail=\"Incomplete OAuth callback\")\n    if not secrets.compare_digest(state, expected):\n        raise HTTPException(status_code=400, detail=\"State mismatch\")\n\n    payload = {\n        \"grant_type\": \"authorization_code\",\n        \"code\": code,\n        \"redirect_uri\": REDIRECT_URI,\n        \"client_id\": CLIENT_ID,\n        \"client_secret\": CLIENT_SECRET,\n        \"code_verifier\": verifier,\n    }\n    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:\n        response = await client.post(\n            TOKEN_URL, data=payload, headers={\"Accept\": \"application\u002Fjson\"}\n        )\n    if response.status_code != 200:\n        raise HTTPException(status_code=502, detail=\"Token exchange failed\")\n\n    await save_grant(user_id=request.session[\"user_id\"], data=response.json())\n    return RedirectResponse(POST_CONNECT_URL, status_code=303)\n",[53,878,879,890,902,907,926,943,960,965,973,982,987,996,1022,1029,1063,1067,1086,1102,1129,1153,1162,1185,1189,1198,1210,1218,1228,1238,1249,1257,1261,1288,1301,1334,1339,1354,1378,1382,1411],{"__ignoreMap":239},[243,880,881,883,885,888],{"class":149,"line":245},[243,882,621],{"class":571},[243,884,624],{"class":259},[243,886,887],{"class":365},"\"\u002Fcallback\"",[243,889,412],{"class":259},[243,891,892,894,896,899],{"class":149,"line":252},[243,893,635],{"class":255},[243,895,638],{"class":255},[243,897,898],{"class":571}," callback",[243,900,901],{"class":259},"(\n",[243,903,904],{"class":149,"line":263},[243,905,906],{"class":259},"    request: Request,\n",[243,908,909,912,914,917,920,922,924],{"class":149,"line":271},[243,910,911],{"class":259},"    code: ",[243,913,584],{"class":394},[243,915,916],{"class":255}," |",[243,918,919],{"class":394}," None",[243,921,398],{"class":255},[243,923,919],{"class":394},[243,925,750],{"class":259},[243,927,928,931,933,935,937,939,941],{"class":149,"line":279},[243,929,930],{"class":259},"    state: ",[243,932,584],{"class":394},[243,934,916],{"class":255},[243,936,919],{"class":394},[243,938,398],{"class":255},[243,940,919],{"class":394},[243,942,750],{"class":259},[243,944,945,948,950,952,954,956,958],{"class":149,"line":287},[243,946,947],{"class":259},"    error: ",[243,949,584],{"class":394},[243,951,916],{"class":255},[243,953,919],{"class":394},[243,955,398],{"class":255},[243,957,919],{"class":394},[243,959,750],{"class":259},[243,961,962],{"class":149,"line":301},[243,963,964],{"class":259},") -> RedirectResponse:\n",[243,966,967,970],{"class":149,"line":308},[243,968,969],{"class":255},"    match",[243,971,972],{"class":259}," error:\n",[243,974,975,978,980],{"class":149,"line":316},[243,976,977],{"class":255},"        case",[243,979,919],{"class":394},[243,981,587],{"class":259},[243,983,984],{"class":149,"line":329},[243,985,986],{"class":255},"            pass\n",[243,988,989,991,994],{"class":149,"line":342},[243,990,977],{"class":255},[243,992,993],{"class":365}," \"access_denied\"",[243,995,587],{"class":259},[243,997,998,1001,1004,1006,1008,1010,1012,1015,1017,1020],{"class":149,"line":347},[243,999,1000],{"class":255},"            raise",[243,1002,1003],{"class":259}," HTTPException(",[243,1005,859],{"class":359},[243,1007,353],{"class":255},[243,1009,152],{"class":394},[243,1011,369],{"class":259},[243,1013,1014],{"class":359},"detail",[243,1016,353],{"class":255},[243,1018,1019],{"class":365},"\"Connection declined by user\"",[243,1021,412],{"class":259},[243,1023,1024,1026],{"class":149,"line":386},[243,1025,977],{"class":255},[243,1027,1028],{"class":259}," _:\n",[243,1030,1031,1033,1035,1037,1039,1041,1043,1045,1047,1049,1052,1054,1057,1059,1061],{"class":149,"line":391},[243,1032,1000],{"class":255},[243,1034,1003],{"class":259},[243,1036,859],{"class":359},[243,1038,353],{"class":255},[243,1040,152],{"class":394},[243,1042,369],{"class":259},[243,1044,1014],{"class":359},[243,1046,353],{"class":255},[243,1048,834],{"class":255},[243,1050,1051],{"class":365},"\"Provider error: ",[243,1053,846],{"class":394},[243,1055,1056],{"class":259},"error",[243,1058,852],{"class":394},[243,1060,837],{"class":365},[243,1062,412],{"class":259},[243,1064,1065],{"class":149,"line":415},[243,1066,305],{"emptyLinePlaceholder":304},[243,1068,1069,1072,1074,1077,1079,1081,1084],{"class":149,"line":434},[243,1070,1071],{"class":259},"    expected ",[243,1073,353],{"class":255},[243,1075,1076],{"class":259}," request.session.pop(",[243,1078,712],{"class":365},[243,1080,369],{"class":259},[243,1082,1083],{"class":394},"None",[243,1085,412],{"class":259},[243,1087,1088,1090,1092,1094,1096,1098,1100],{"class":149,"line":453},[243,1089,650],{"class":259},[243,1091,353],{"class":255},[243,1093,1076],{"class":259},[243,1095,696],{"class":365},[243,1097,369],{"class":259},[243,1099,1083],{"class":394},[243,1101,412],{"class":259},[243,1103,1104,1107,1110,1113,1116,1119,1121,1124,1126],{"class":149,"line":472},[243,1105,1106],{"class":255},"    if",[243,1108,1109],{"class":255}," not",[243,1111,1112],{"class":259}," (code ",[243,1114,1115],{"class":255},"and",[243,1117,1118],{"class":259}," state ",[243,1120,1115],{"class":255},[243,1122,1123],{"class":259}," expected ",[243,1125,1115],{"class":255},[243,1127,1128],{"class":259}," verifier):\n",[243,1130,1131,1134,1136,1138,1140,1142,1144,1146,1148,1151],{"class":149,"line":491},[243,1132,1133],{"class":255},"        raise",[243,1135,1003],{"class":259},[243,1137,859],{"class":359},[243,1139,353],{"class":255},[243,1141,152],{"class":394},[243,1143,369],{"class":259},[243,1145,1014],{"class":359},[243,1147,353],{"class":255},[243,1149,1150],{"class":365},"\"Incomplete OAuth callback\"",[243,1152,412],{"class":259},[243,1154,1155,1157,1159],{"class":149,"line":510},[243,1156,1106],{"class":255},[243,1158,1109],{"class":255},[243,1160,1161],{"class":259}," secrets.compare_digest(state, expected):\n",[243,1163,1164,1166,1168,1170,1172,1174,1176,1178,1180,1183],{"class":149,"line":530},[243,1165,1133],{"class":255},[243,1167,1003],{"class":259},[243,1169,859],{"class":359},[243,1171,353],{"class":255},[243,1173,152],{"class":394},[243,1175,369],{"class":259},[243,1177,1014],{"class":359},[243,1179,353],{"class":255},[243,1181,1182],{"class":365},"\"State mismatch\"",[243,1184,412],{"class":259},[243,1186,1187],{"class":149,"line":555},[243,1188,305],{"emptyLinePlaceholder":304},[243,1190,1191,1194,1196],{"class":149,"line":560},[243,1192,1193],{"class":259},"    payload ",[243,1195,353],{"class":255},[243,1197,735],{"class":259},[243,1199,1200,1203,1205,1208],{"class":149,"line":565},[243,1201,1202],{"class":365},"        \"grant_type\"",[243,1204,744],{"class":259},[243,1206,1207],{"class":365},"\"authorization_code\"",[243,1209,750],{"class":259},[243,1211,1212,1215],{"class":149,"line":590},[243,1213,1214],{"class":365},"        \"code\"",[243,1216,1217],{"class":259},": code,\n",[243,1219,1220,1222,1224,1226],{"class":149,"line":608},[243,1221,768],{"class":365},[243,1223,744],{"class":259},[243,1225,475],{"class":394},[243,1227,750],{"class":259},[243,1229,1230,1232,1234,1236],{"class":149,"line":613},[243,1231,756],{"class":365},[243,1233,744],{"class":259},[243,1235,437],{"class":394},[243,1237,750],{"class":259},[243,1239,1240,1243,1245,1247],{"class":149,"line":618},[243,1241,1242],{"class":365},"        \"client_secret\"",[243,1244,744],{"class":259},[243,1246,456],{"class":394},[243,1248,750],{"class":259},[243,1250,1251,1254],{"class":149,"line":632},[243,1252,1253],{"class":365},"        \"code_verifier\"",[243,1255,1256],{"class":259},": verifier,\n",[243,1258,1259],{"class":149,"line":647},[243,1260,823],{"class":259},[243,1262,1263,1266,1269,1272,1275,1277,1279,1282,1285],{"class":149,"line":663},[243,1264,1265],{"class":255},"    async",[243,1267,1268],{"class":255}," with",[243,1270,1271],{"class":259}," httpx.AsyncClient(",[243,1273,1274],{"class":359},"timeout",[243,1276,353],{"class":255},[243,1278,533],{"class":394},[243,1280,1281],{"class":259},") ",[243,1283,1284],{"class":255},"as",[243,1286,1287],{"class":259}," client:\n",[243,1289,1290,1293,1295,1298],{"class":149,"line":674},[243,1291,1292],{"class":259},"        response ",[243,1294,353],{"class":255},[243,1296,1297],{"class":255}," await",[243,1299,1300],{"class":259}," client.post(\n",[243,1302,1303,1306,1308,1311,1313,1316,1319,1321,1323,1326,1328,1331],{"class":149,"line":690},[243,1304,1305],{"class":394},"            TOKEN_URL",[243,1307,369],{"class":259},[243,1309,1310],{"class":359},"data",[243,1312,353],{"class":255},[243,1314,1315],{"class":259},"payload, ",[243,1317,1318],{"class":359},"headers",[243,1320,353],{"class":255},[243,1322,846],{"class":259},[243,1324,1325],{"class":365},"\"Accept\"",[243,1327,744],{"class":259},[243,1329,1330],{"class":365},"\"application\u002Fjson\"",[243,1332,1333],{"class":259},"}\n",[243,1335,1336],{"class":149,"line":707},[243,1337,1338],{"class":259},"        )\n",[243,1340,1341,1343,1346,1349,1352],{"class":149,"line":722},[243,1342,1106],{"class":255},[243,1344,1345],{"class":259}," response.status_code ",[243,1347,1348],{"class":255},"!=",[243,1350,1351],{"class":394}," 200",[243,1353,587],{"class":259},[243,1355,1356,1358,1360,1362,1364,1367,1369,1371,1373,1376],{"class":149,"line":727},[243,1357,1133],{"class":255},[243,1359,1003],{"class":259},[243,1361,859],{"class":359},[243,1363,353],{"class":255},[243,1365,1366],{"class":394},"502",[243,1368,369],{"class":259},[243,1370,1014],{"class":359},[243,1372,353],{"class":255},[243,1374,1375],{"class":365},"\"Token exchange failed\"",[243,1377,412],{"class":259},[243,1379,1380],{"class":149,"line":738},[243,1381,305],{"emptyLinePlaceholder":304},[243,1383,1384,1387,1390,1393,1395,1398,1401,1404,1406,1408],{"class":149,"line":753},[243,1385,1386],{"class":255},"    await",[243,1388,1389],{"class":259}," save_grant(",[243,1391,1392],{"class":359},"user_id",[243,1394,353],{"class":255},[243,1396,1397],{"class":259},"request.session[",[243,1399,1400],{"class":365},"\"user_id\"",[243,1402,1403],{"class":259},"], ",[243,1405,1310],{"class":359},[243,1407,353],{"class":255},[243,1409,1410],{"class":259},"response.json())\n",[243,1412,1413,1415,1417,1419,1421,1423,1425,1428],{"class":149,"line":765},[243,1414,593],{"class":255},[243,1416,831],{"class":259},[243,1418,513],{"class":394},[243,1420,369],{"class":259},[243,1422,859],{"class":359},[243,1424,353],{"class":255},[243,1426,1427],{"class":394},"303",[243,1429,412],{"class":259},[14,1431,1432,1433,1436,1437,1440,1441,1444],{},"Two details that bite people in production. First, ",[53,1434,1435],{},"redirect_uri"," must match the registered value byte for byte, including the trailing slash and the scheme — a staging environment on ",[53,1438,1439],{},"http:\u002F\u002Flocalhost"," and production on ",[53,1442,1443],{},"https:\u002F\u002F"," need two separate registered URIs, not one wildcard. Second, authorization codes are single-use and usually expire in sixty seconds, so a user who double-clicks the consent button generates a second callback that fails; return a friendly \"already connected\" page rather than a 400 when the state is already consumed.",[42,1446],{},[45,1448,1450],{"id":1449},"storing-tokens-without-leaking-them","Storing tokens without leaking them",[14,1452,1453],{},"A refresh token is a long-lived bearer credential for someone else's account. Treat it like a password, not like a cache entry. One row per user per provider, both tokens encrypted at rest with a key that lives in your secret manager rather than your database, and a unique constraint that makes reconnecting an upsert instead of a duplicate.",[61,1455,70,1460,70,1463,70,1466,70,1470,70,1477,70,1484,70,1486,70,1491,70,1496,70,1499,70,1503,70,1506,70,1510,70,1512,70,1516,70,1519,70,1523,70,1526,70,1531,70,1534,70,1537,70,1541,70,1545,70,1549,70,1555,70,1558,70,1562,70,1565,70,1569,70,1572,70,1576,70,1579],{"viewBox":1456,"role":64,"ariaLabelledBy":1457,"xmlns":68,"style":69},"0 0 720 300",[1458,1459],"oa2s-t","oa2s-d",[72,1461,1462],{"id":1458},"Shape of the stored OAuth grant row",[76,1464,1465],{"id":1459},"An annotated database row for the oauth_grants table listing user and provider under a unique constraint, encrypted access and refresh token columns, an expiry timestamp and a status column, with callouts explaining encryption at rest and refreshing 300 seconds early.",[80,1467],{"x":82,"y":82,"width":1468,"height":1469,"fill":85},"720","300",[87,1471,89,1472,70],{},[91,1473,99,1475,89],{"id":1474,"viewBox":94,"refX":95,"refY":96,"markerWidth":97,"markerHeight":97,"orient":98},"oa2s-arrow",[101,1476],{"d":103,"fill":104},[80,1478],{"x":1479,"y":1479,"width":1480,"height":1481,"rx":1482,"fill":1483,"stroke":125,"style":126},"30","380","222","10","var(--c-surface-alt)",[80,1485],{"x":1479,"y":1479,"width":1480,"height":110,"rx":1482,"fill":85,"stroke":125,"style":126},[115,1487,1490],{"x":1488,"y":151,"fill":119,"style":1489},"45","font-size:13;font-family:var(--font-sans);","oauth_grants",[115,1492,1495],{"x":1488,"y":1493,"fill":119,"style":1494},"92","font-size:12;font-family:var(--font-sans);","user_id, provider",[115,1497,1498],{"x":200,"y":1493,"fill":104,"style":1494},"unique",[115,1500,1502],{"x":1488,"y":1501,"fill":119,"style":1494},"124","access_token_enc",[115,1504,1505],{"x":200,"y":1501,"fill":104,"style":1494},"bytea",[115,1507,1509],{"x":1488,"y":1508,"fill":119,"style":1494},"156","refresh_token_enc",[115,1511,1505],{"x":200,"y":1508,"fill":104,"style":1494},[115,1513,1515],{"x":1488,"y":1514,"fill":119,"style":1494},"188","expires_at",[115,1517,1518],{"x":200,"y":1514,"fill":104,"style":1494},"timestamptz",[115,1520,1522],{"x":1488,"y":1521,"fill":119,"style":1494},"220","status",[115,1524,1525],{"x":200,"y":1521,"fill":104,"style":1494},"active | revoked",[149,1527],{"x1":1479,"y1":1528,"x2":1529,"y2":1528,"stroke":112,"style":1530},"104","410","stroke-width:1;",[149,1532],{"x1":1479,"y1":1533,"x2":1529,"y2":1533,"stroke":112,"style":1530},"168",[149,1535],{"x1":1479,"y1":1536,"x2":1529,"y2":1536,"stroke":112,"style":1530},"200",[80,1538],{"x":1539,"y":1479,"width":1540,"height":151,"rx":111,"fill":85,"stroke":134,"style":126},"460","230",[115,1542,1544],{"x":1543,"y":151,"fill":119,"style":1494},"475","One row per user and",[115,1546,1548],{"x":1543,"y":1547,"fill":119,"style":1494},"70","provider — upsert it",[149,1550],{"x1":1551,"y1":1493,"x2":1552,"y2":1553,"stroke":104,"style":1554},"414","456","60","stroke-width:1.5;marker-end:url(#oa2s-arrow);",[80,1556],{"x":1539,"y":1557,"width":1540,"height":151,"rx":111,"fill":85,"stroke":143,"style":126},"110",[115,1559,1561],{"x":1543,"y":1560,"fill":119,"style":1494},"132","Encrypt both tokens",[115,1563,1564],{"x":1543,"y":109,"fill":119,"style":1494},"before they hit disk",[149,1566],{"x1":1551,"y1":1567,"x2":1552,"y2":1568,"stroke":104,"style":1554},"140","138",[80,1570],{"x":1539,"y":162,"width":1540,"height":151,"rx":111,"fill":85,"stroke":1571,"style":126},"var(--c-yellow)",[115,1573,1575],{"x":1543,"y":1574,"fill":119,"style":1494},"212","Refresh 300 s before",[115,1577,1578],{"x":1543,"y":1540,"fill":119,"style":1494},"this, never after it",[149,1580],{"x1":1551,"y1":1514,"x2":1552,"y2":1574,"stroke":104,"style":1554},[14,1582,1583,1584,1588],{},"The model below uses SQLAlchemy 2.0 typed mappings, which pair well with ",[23,1585,1587],{"href":1586},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","async database access"," under an async engine. Fernet gives you authenticated symmetric encryption in one line; if you already run a KMS, swap the two helper calls and keep the rest.",[234,1590,1592],{"className":236,"code":1591,"language":238,"meta":239,"style":239},"# app\u002Fmodels.py\nimport os\nfrom datetime import datetime, timedelta, timezone\n\nfrom cryptography.fernet import Fernet\nfrom sqlalchemy import String, UniqueConstraint\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\nFERNET = Fernet(os.getenv(\"TOKEN_ENC_KEY\", Fernet.generate_key().decode()).encode())\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass OAuthGrant(Base):\n    __tablename__ = \"oauth_grants\"\n    __table_args__ = (UniqueConstraint(\"user_id\", \"provider\", name=\"uq_user_provider\"),)\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    user_id: Mapped[str] = mapped_column(String(64), index=True)\n    provider: Mapped[str] = mapped_column(String(32))\n    access_token_enc: Mapped[bytes]\n    refresh_token_enc: Mapped[bytes | None]\n    scope: Mapped[str] = mapped_column(String(512), default=\"\")\n    expires_at: Mapped[datetime]\n    status: Mapped[str] = mapped_column(String(16), default=\"active\")\n\n    def set_tokens(self, data: dict) -> None:\n        self.access_token_enc = FERNET.encrypt(data[\"access_token\"].encode())\n        if refresh := data.get(\"refresh_token\"):\n            self.refresh_token_enc = FERNET.encrypt(refresh.encode())\n        ttl = int(data.get(\"expires_in\", os.getenv(\"PROVIDER_DEFAULT_TTL\", \"3600\")))\n        self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)\n        self.scope = data.get(\"scope\", self.scope)\n",[53,1593,1594,1599,1605,1617,1621,1633,1645,1657,1661,1677,1681,1685,1701,1706,1710,1714,1728,1738,1768,1772,1800,1828,1845,1855,1868,1895,1900,1927,1931,1951,1973,1992,2007,2037,2063],{"__ignoreMap":239},[243,1595,1596],{"class":149,"line":245},[243,1597,1598],{"class":248},"# app\u002Fmodels.py\n",[243,1600,1601,1603],{"class":149,"line":252},[243,1602,256],{"class":255},[243,1604,276],{"class":259},[243,1606,1607,1609,1612,1614],{"class":149,"line":263},[243,1608,290],{"class":255},[243,1610,1611],{"class":259}," datetime ",[243,1613,256],{"class":255},[243,1615,1616],{"class":259}," datetime, timedelta, timezone\n",[243,1618,1619],{"class":149,"line":271},[243,1620,305],{"emptyLinePlaceholder":304},[243,1622,1623,1625,1628,1630],{"class":149,"line":279},[243,1624,290],{"class":255},[243,1626,1627],{"class":259}," cryptography.fernet ",[243,1629,256],{"class":255},[243,1631,1632],{"class":259}," Fernet\n",[243,1634,1635,1637,1640,1642],{"class":149,"line":287},[243,1636,290],{"class":255},[243,1638,1639],{"class":259}," sqlalchemy ",[243,1641,256],{"class":255},[243,1643,1644],{"class":259}," String, UniqueConstraint\n",[243,1646,1647,1649,1652,1654],{"class":149,"line":301},[243,1648,290],{"class":255},[243,1650,1651],{"class":259}," sqlalchemy.orm ",[243,1653,256],{"class":255},[243,1655,1656],{"class":259}," DeclarativeBase, Mapped, mapped_column\n",[243,1658,1659],{"class":149,"line":308},[243,1660,305],{"emptyLinePlaceholder":304},[243,1662,1663,1666,1668,1671,1674],{"class":149,"line":316},[243,1664,1665],{"class":394},"FERNET",[243,1667,398],{"class":255},[243,1669,1670],{"class":259}," Fernet(os.getenv(",[243,1672,1673],{"class":365},"\"TOKEN_ENC_KEY\"",[243,1675,1676],{"class":259},", Fernet.generate_key().decode()).encode())\n",[243,1678,1679],{"class":149,"line":329},[243,1680,305],{"emptyLinePlaceholder":304},[243,1682,1683],{"class":149,"line":342},[243,1684,305],{"emptyLinePlaceholder":304},[243,1686,1687,1690,1693,1695,1698],{"class":149,"line":347},[243,1688,1689],{"class":255},"class",[243,1691,1692],{"class":571}," Base",[243,1694,624],{"class":259},[243,1696,1697],{"class":571},"DeclarativeBase",[243,1699,1700],{"class":259},"):\n",[243,1702,1703],{"class":149,"line":386},[243,1704,1705],{"class":255},"    pass\n",[243,1707,1708],{"class":149,"line":391},[243,1709,305],{"emptyLinePlaceholder":304},[243,1711,1712],{"class":149,"line":415},[243,1713,305],{"emptyLinePlaceholder":304},[243,1715,1716,1718,1721,1723,1726],{"class":149,"line":434},[243,1717,1689],{"class":255},[243,1719,1720],{"class":571}," OAuthGrant",[243,1722,624],{"class":259},[243,1724,1725],{"class":571},"Base",[243,1727,1700],{"class":259},[243,1729,1730,1733,1735],{"class":149,"line":453},[243,1731,1732],{"class":259},"    __tablename__ ",[243,1734,353],{"class":255},[243,1736,1737],{"class":365}," \"oauth_grants\"\n",[243,1739,1740,1743,1745,1748,1750,1752,1755,1757,1760,1762,1765],{"class":149,"line":472},[243,1741,1742],{"class":259},"    __table_args__ ",[243,1744,353],{"class":255},[243,1746,1747],{"class":259}," (UniqueConstraint(",[243,1749,1400],{"class":365},[243,1751,369],{"class":259},[243,1753,1754],{"class":365},"\"provider\"",[243,1756,369],{"class":259},[243,1758,1759],{"class":359},"name",[243,1761,353],{"class":255},[243,1763,1764],{"class":365},"\"uq_user_provider\"",[243,1766,1767],{"class":259},"),)\n",[243,1769,1770],{"class":149,"line":491},[243,1771,305],{"emptyLinePlaceholder":304},[243,1773,1774,1777,1780,1783,1785,1787,1790,1793,1795,1798],{"class":149,"line":510},[243,1775,1776],{"class":394},"    id",[243,1778,1779],{"class":259},": Mapped[",[243,1781,1782],{"class":394},"int",[243,1784,699],{"class":259},[243,1786,353],{"class":255},[243,1788,1789],{"class":259}," mapped_column(",[243,1791,1792],{"class":359},"primary_key",[243,1794,353],{"class":255},[243,1796,1797],{"class":394},"True",[243,1799,412],{"class":259},[243,1801,1802,1805,1807,1809,1811,1814,1816,1819,1822,1824,1826],{"class":149,"line":530},[243,1803,1804],{"class":259},"    user_id: Mapped[",[243,1806,584],{"class":394},[243,1808,699],{"class":259},[243,1810,353],{"class":255},[243,1812,1813],{"class":259}," mapped_column(String(",[243,1815,658],{"class":394},[243,1817,1818],{"class":259},"), ",[243,1820,1821],{"class":359},"index",[243,1823,353],{"class":255},[243,1825,1797],{"class":394},[243,1827,412],{"class":259},[243,1829,1830,1833,1835,1837,1839,1841,1843],{"class":149,"line":555},[243,1831,1832],{"class":259},"    provider: Mapped[",[243,1834,584],{"class":394},[243,1836,699],{"class":259},[243,1838,353],{"class":255},[243,1840,1813],{"class":259},[243,1842,685],{"class":394},[243,1844,552],{"class":259},[243,1846,1847,1850,1852],{"class":149,"line":560},[243,1848,1849],{"class":259},"    access_token_enc: Mapped[",[243,1851,578],{"class":394},[243,1853,1854],{"class":259},"]\n",[243,1856,1857,1860,1862,1864,1866],{"class":149,"line":565},[243,1858,1859],{"class":259},"    refresh_token_enc: Mapped[",[243,1861,578],{"class":394},[243,1863,916],{"class":255},[243,1865,919],{"class":394},[243,1867,1854],{"class":259},[243,1869,1870,1873,1875,1877,1879,1881,1884,1886,1889,1891,1893],{"class":149,"line":590},[243,1871,1872],{"class":259},"    scope: Mapped[",[243,1874,584],{"class":394},[243,1876,699],{"class":259},[243,1878,353],{"class":255},[243,1880,1813],{"class":259},[243,1882,1883],{"class":394},"512",[243,1885,1818],{"class":259},[243,1887,1888],{"class":359},"default",[243,1890,353],{"class":255},[243,1892,409],{"class":365},[243,1894,412],{"class":259},[243,1896,1897],{"class":149,"line":608},[243,1898,1899],{"class":259},"    expires_at: Mapped[datetime]\n",[243,1901,1902,1905,1907,1909,1911,1913,1916,1918,1920,1922,1925],{"class":149,"line":613},[243,1903,1904],{"class":259},"    status: Mapped[",[243,1906,584],{"class":394},[243,1908,699],{"class":259},[243,1910,353],{"class":255},[243,1912,1813],{"class":259},[243,1914,1915],{"class":394},"16",[243,1917,1818],{"class":259},[243,1919,1888],{"class":359},[243,1921,353],{"class":255},[243,1923,1924],{"class":365},"\"active\"",[243,1926,412],{"class":259},[243,1928,1929],{"class":149,"line":618},[243,1930,305],{"emptyLinePlaceholder":304},[243,1932,1933,1936,1939,1942,1945,1947,1949],{"class":149,"line":632},[243,1934,1935],{"class":255},"    def",[243,1937,1938],{"class":571}," set_tokens",[243,1940,1941],{"class":259},"(self, data: ",[243,1943,1944],{"class":394},"dict",[243,1946,581],{"class":259},[243,1948,1083],{"class":394},[243,1950,587],{"class":259},[243,1952,1953,1956,1959,1961,1964,1967,1970],{"class":149,"line":647},[243,1954,1955],{"class":394},"        self",[243,1957,1958],{"class":259},".access_token_enc ",[243,1960,353],{"class":255},[243,1962,1963],{"class":394}," FERNET",[243,1965,1966],{"class":259},".encrypt(data[",[243,1968,1969],{"class":365},"\"access_token\"",[243,1971,1972],{"class":259},"].encode())\n",[243,1974,1975,1978,1981,1984,1987,1990],{"class":149,"line":663},[243,1976,1977],{"class":255},"        if",[243,1979,1980],{"class":259}," refresh ",[243,1982,1983],{"class":255},":=",[243,1985,1986],{"class":259}," data.get(",[243,1988,1989],{"class":365},"\"refresh_token\"",[243,1991,1700],{"class":259},[243,1993,1994,1997,2000,2002,2004],{"class":149,"line":674},[243,1995,1996],{"class":394},"            self",[243,1998,1999],{"class":259},".refresh_token_enc ",[243,2001,353],{"class":255},[243,2003,1963],{"class":394},[243,2005,2006],{"class":259},".encrypt(refresh.encode())\n",[243,2008,2009,2012,2014,2017,2020,2023,2026,2029,2031,2034],{"class":149,"line":690},[243,2010,2011],{"class":259},"        ttl ",[243,2013,353],{"class":255},[243,2015,2016],{"class":394}," int",[243,2018,2019],{"class":259},"(data.get(",[243,2021,2022],{"class":365},"\"expires_in\"",[243,2024,2025],{"class":259},", os.getenv(",[243,2027,2028],{"class":365},"\"PROVIDER_DEFAULT_TTL\"",[243,2030,369],{"class":259},[243,2032,2033],{"class":365},"\"3600\"",[243,2035,2036],{"class":259},")))\n",[243,2038,2039,2041,2044,2046,2049,2052,2055,2058,2060],{"class":149,"line":707},[243,2040,1955],{"class":394},[243,2042,2043],{"class":259},".expires_at ",[243,2045,353],{"class":255},[243,2047,2048],{"class":259}," datetime.now(timezone.utc) ",[243,2050,2051],{"class":255},"+",[243,2053,2054],{"class":259}," timedelta(",[243,2056,2057],{"class":359},"seconds",[243,2059,353],{"class":255},[243,2061,2062],{"class":259},"ttl)\n",[243,2064,2065,2067,2070,2072,2074,2077,2079,2082],{"class":149,"line":722},[243,2066,1955],{"class":394},[243,2068,2069],{"class":259},".scope ",[243,2071,353],{"class":255},[243,2073,1986],{"class":259},[243,2075,2076],{"class":365},"\"scope\"",[243,2078,369],{"class":259},[243,2080,2081],{"class":394},"self",[243,2083,2084],{"class":259},".scope)\n",[14,2086,2087,2088,2091,2092,2094],{},"Note the ",[53,2089,2090],{},"if refresh := data.get(\"refresh_token\")"," guard. Many providers return a refresh token only on the first authorization and omit it from every subsequent refresh response. Overwriting the column with ",[53,2093,1083],{}," there is the single most common way builders lock themselves out of a customer account — the access token expires an hour later and there is nothing left to refresh with.",[42,2096],{},[45,2098,2100],{"id":2099},"refreshing-early-under-a-lock","Refreshing early, under a lock",[14,2102,2103,2104,2108],{},"Refresh proactively inside a skew window, not reactively on a 401. If you wait for the provider to reject you, every in-flight request for that customer fails at once, and you will spend an afternoon ",[23,2105,2107],{"href":2106},"\u002Fgetting-started-with-python-apis-for-builders\u002Fparsing-json-responses\u002Fdebugging-401-unauthorized-api-errors\u002F","debugging 401 errors"," that were entirely predictable. Three hundred seconds is the right window for a one-hour token: long enough to absorb clock drift and a retry, short enough that you refresh once per hour per customer instead of constantly.",[61,2110,70,2115,70,2118,70,2121,70,2124,70,2131,70,2134,70,2139,70,2142,70,2148,70,2151,70,2155,70,2158,70,2162,70,2167,70,2172,70,2175,70,2178,70,2182,70,2185,70,2187,70,2189,70,2193,70,2196,70,2200,70,2203],{"viewBox":2111,"role":64,"ariaLabelledBy":2112,"xmlns":68,"style":69},"0 0 720 240",[2113,2114],"oa2t-t","oa2t-d",[72,2116,2117],{"id":2113},"Access token lifetime and the refresh skew window",[76,2119,2120],{"id":2114},"A timeline of a 3600 second access token showing requests served from the cached token for the first 3300 seconds, then a 300 second skew window in which the background job refreshes before expiry.",[80,2122],{"x":82,"y":82,"width":1468,"height":2123,"fill":85},"240",[87,2125,89,2126,70],{},[91,2127,99,2129,89],{"id":2128,"viewBox":94,"refX":95,"refY":96,"markerWidth":97,"markerHeight":97,"orient":98},"oa2t-arrow",[101,2130],{"d":103,"fill":104},[80,2132],{"x":1553,"y":107,"width":2123,"height":2133,"rx":111,"fill":85,"stroke":125,"style":126},"50",[115,2135,2138],{"x":2136,"y":2137,"fill":119,"style":1494},"75","42","Served from the cached",[115,2140,2141],{"x":2136,"y":1553,"fill":119,"style":1494},"access token — zero cost",[149,2143],{"x1":2144,"y1":2145,"x2":2144,"y2":2146,"stroke":104,"style":2147},"180","72","101","stroke-width:1.5;marker-end:url(#oa2t-arrow);",[80,2149],{"x":2150,"y":107,"width":2123,"height":2133,"rx":111,"fill":85,"stroke":143,"style":126},"430",[115,2152,2154],{"x":2153,"y":2137,"fill":119,"style":1494},"445","Sweep job refreshes in",[115,2156,2157],{"x":2153,"y":1553,"fill":119,"style":1494},"the 300 s skew window",[149,2159],{"x1":2160,"y1":2145,"x2":2161,"y2":2146,"stroke":104,"style":2147},"620","632",[80,2163],{"x":1553,"y":2164,"width":2165,"height":685,"rx":2166,"fill":1483,"stroke":125,"style":126},"103","550","6",[115,2168,2171],{"x":2169,"y":1501,"fill":119,"style":2170},"335","text-anchor:middle;font-size:12;font-family:var(--font-sans);","access token valid — 3600 s",[80,2173],{"x":2174,"y":2164,"width":2133,"height":685,"rx":2166,"fill":85,"stroke":143,"style":126},"610",[149,2176],{"x1":1553,"y1":109,"x2":2177,"y2":109,"stroke":112,"style":113},"660",[149,2179],{"x1":1553,"y1":2180,"x2":1553,"y2":2181,"stroke":104,"style":113},"145","155",[149,2183],{"x1":2184,"y1":2180,"x2":2184,"y2":2181,"stroke":104,"style":113},"360",[149,2186],{"x1":2174,"y1":2180,"x2":2174,"y2":2181,"stroke":143,"style":126},[149,2188],{"x1":2177,"y1":2180,"x2":2177,"y2":2181,"stroke":104,"style":113},[115,2190,2192],{"x":1553,"y":2191,"fill":104,"style":164},"172","0 s",[115,2194,2195],{"x":2184,"y":2191,"fill":104,"style":164},"1800 s",[115,2197,2199],{"x":2174,"y":2198,"fill":119,"style":164},"196","3300 s",[115,2201,2202],{"x":2177,"y":2191,"fill":104,"style":164},"3600 s",[115,2204,2205],{"x":1553,"y":1521,"fill":119,"style":1494},"Refresh after 3600 s and every in-flight call 401s at once.",[14,2207,2208,2209,2212],{},"Concurrency is the real hazard here. Two workers that notice the same expiring token at the same moment will both call the token endpoint, and providers that use refresh token rotation will invalidate the first response when the second arrives — you end up with a stored token the provider already retired. A ",[53,2210,2211],{},"SELECT ... FOR UPDATE"," on the grant row serialises it for free, which is why this function takes a session and runs inside a transaction.",[234,2214,2216],{"className":236,"code":2215,"language":238,"meta":239,"style":239},"# app\u002Ftokens.py\nimport os\nfrom datetime import datetime, timedelta, timezone\n\nimport httpx\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.models import FERNET, OAuthGrant\nfrom app.oauth_client import CLIENT_ID, CLIENT_SECRET, HTTP_TIMEOUT, TOKEN_URL\n\nSKEW = int(os.getenv(\"OAUTH_REFRESH_SKEW_SECONDS\", \"300\"))\n\n\nclass GrantRevoked(Exception):\n    \"\"\"The customer or provider withdrew this authorization.\"\"\"\n\n\nasync def valid_access_token(\n    session: AsyncSession, user_id: str, provider: str\n) -> str:\n    stmt = (\n        select(OAuthGrant)\n        .where(OAuthGrant.user_id == user_id, OAuthGrant.provider == provider)\n        .with_for_update()\n    )\n    grant = await session.scalar(stmt)\n    if grant is None or grant.status != \"active\":\n        raise GrantRevoked(f\"{provider} not connected for {user_id}\")\n\n    deadline = grant.expires_at - timedelta(seconds=SKEW)\n    if deadline > datetime.now(timezone.utc):\n        return FERNET.decrypt(grant.access_token_enc).decode()\n\n    if grant.refresh_token_enc is None:\n        grant.status = \"revoked\"\n        await session.commit()\n        raise GrantRevoked(\"no refresh token stored\")\n\n    payload = {\n        \"grant_type\": \"refresh_token\",\n        \"refresh_token\": FERNET.decrypt(grant.refresh_token_enc).decode(),\n        \"client_id\": CLIENT_ID,\n        \"client_secret\": CLIENT_SECRET,\n    }\n    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:\n        response = await client.post(TOKEN_URL, data=payload)\n\n    match response.status_code:\n        case 200:\n            grant.set_tokens(response.json())\n            await session.commit()\n            return FERNET.decrypt(grant.access_token_enc).decode()\n        case 400 | 401:\n            grant.status = \"revoked\"\n            await session.commit()\n            raise GrantRevoked(response.json().get(\"error\", \"invalid_grant\"))\n        case _:\n            response.raise_for_status()\n            raise GrantRevoked(\"unreachable\")\n",[53,2217,2218,2223,2229,2239,2243,2249,2260,2272,2276,2290,2315,2319,2340,2344,2348,2362,2367,2371,2375,2386,2399,2407,2417,2422,2437,2442,2447,2459,2484,2515,2519,2542,2555,2565,2569,2582,2592,2600,2611,2615,2623,2633,2645,2655,2665,2669,2690,2713,2718,2726,2735,2741,2749,2759,2774,2784,2791,2809,2816,2822],{"__ignoreMap":239},[243,2219,2220],{"class":149,"line":245},[243,2221,2222],{"class":248},"# app\u002Ftokens.py\n",[243,2224,2225,2227],{"class":149,"line":252},[243,2226,256],{"class":255},[243,2228,276],{"class":259},[243,2230,2231,2233,2235,2237],{"class":149,"line":263},[243,2232,290],{"class":255},[243,2234,1611],{"class":259},[243,2236,256],{"class":255},[243,2238,1616],{"class":259},[243,2240,2241],{"class":149,"line":271},[243,2242,305],{"emptyLinePlaceholder":304},[243,2244,2245,2247],{"class":149,"line":279},[243,2246,256],{"class":255},[243,2248,313],{"class":259},[243,2250,2251,2253,2255,2257],{"class":149,"line":287},[243,2252,290],{"class":255},[243,2254,1639],{"class":259},[243,2256,256],{"class":255},[243,2258,2259],{"class":259}," select\n",[243,2261,2262,2264,2267,2269],{"class":149,"line":301},[243,2263,290],{"class":255},[243,2265,2266],{"class":259}," sqlalchemy.ext.asyncio ",[243,2268,256],{"class":255},[243,2270,2271],{"class":259}," AsyncSession\n",[243,2273,2274],{"class":149,"line":308},[243,2275,305],{"emptyLinePlaceholder":304},[243,2277,2278,2280,2283,2285,2287],{"class":149,"line":316},[243,2279,290],{"class":255},[243,2281,2282],{"class":259}," app.models ",[243,2284,256],{"class":255},[243,2286,1963],{"class":394},[243,2288,2289],{"class":259},", OAuthGrant\n",[243,2291,2292,2294,2297,2299,2302,2304,2306,2308,2310,2312],{"class":149,"line":329},[243,2293,290],{"class":255},[243,2295,2296],{"class":259}," app.oauth_client ",[243,2298,256],{"class":255},[243,2300,2301],{"class":394}," CLIENT_ID",[243,2303,369],{"class":259},[243,2305,456],{"class":394},[243,2307,369],{"class":259},[243,2309,533],{"class":394},[243,2311,369],{"class":259},[243,2313,2314],{"class":394},"TOKEN_URL\n",[243,2316,2317],{"class":149,"line":342},[243,2318,305],{"emptyLinePlaceholder":304},[243,2320,2321,2324,2326,2328,2330,2333,2335,2338],{"class":149,"line":347},[243,2322,2323],{"class":394},"SKEW",[243,2325,398],{"class":255},[243,2327,2016],{"class":394},[243,2329,541],{"class":259},[243,2331,2332],{"class":365},"\"OAUTH_REFRESH_SKEW_SECONDS\"",[243,2334,369],{"class":259},[243,2336,2337],{"class":365},"\"300\"",[243,2339,552],{"class":259},[243,2341,2342],{"class":149,"line":386},[243,2343,305],{"emptyLinePlaceholder":304},[243,2345,2346],{"class":149,"line":391},[243,2347,305],{"emptyLinePlaceholder":304},[243,2349,2350,2352,2355,2357,2360],{"class":149,"line":415},[243,2351,1689],{"class":255},[243,2353,2354],{"class":571}," GrantRevoked",[243,2356,624],{"class":259},[243,2358,2359],{"class":394},"Exception",[243,2361,1700],{"class":259},[243,2363,2364],{"class":149,"line":434},[243,2365,2366],{"class":365},"    \"\"\"The customer or provider withdrew this authorization.\"\"\"\n",[243,2368,2369],{"class":149,"line":453},[243,2370,305],{"emptyLinePlaceholder":304},[243,2372,2373],{"class":149,"line":472},[243,2374,305],{"emptyLinePlaceholder":304},[243,2376,2377,2379,2381,2384],{"class":149,"line":491},[243,2378,635],{"class":255},[243,2380,638],{"class":255},[243,2382,2383],{"class":571}," valid_access_token",[243,2385,901],{"class":259},[243,2387,2388,2391,2393,2396],{"class":149,"line":510},[243,2389,2390],{"class":259},"    session: AsyncSession, user_id: ",[243,2392,584],{"class":394},[243,2394,2395],{"class":259},", provider: ",[243,2397,2398],{"class":394},"str\n",[243,2400,2401,2403,2405],{"class":149,"line":530},[243,2402,581],{"class":259},[243,2404,584],{"class":394},[243,2406,587],{"class":259},[243,2408,2409,2412,2414],{"class":149,"line":555},[243,2410,2411],{"class":259},"    stmt ",[243,2413,353],{"class":255},[243,2415,2416],{"class":259}," (\n",[243,2418,2419],{"class":149,"line":560},[243,2420,2421],{"class":259},"        select(OAuthGrant)\n",[243,2423,2424,2427,2429,2432,2434],{"class":149,"line":565},[243,2425,2426],{"class":259},"        .where(OAuthGrant.user_id ",[243,2428,872],{"class":255},[243,2430,2431],{"class":259}," user_id, OAuthGrant.provider ",[243,2433,872],{"class":255},[243,2435,2436],{"class":259}," provider)\n",[243,2438,2439],{"class":149,"line":590},[243,2440,2441],{"class":259},"        .with_for_update()\n",[243,2443,2444],{"class":149,"line":608},[243,2445,2446],{"class":259},"    )\n",[243,2448,2449,2452,2454,2456],{"class":149,"line":613},[243,2450,2451],{"class":259},"    grant ",[243,2453,353],{"class":255},[243,2455,1297],{"class":255},[243,2457,2458],{"class":259}," session.scalar(stmt)\n",[243,2460,2461,2463,2466,2469,2471,2474,2477,2479,2482],{"class":149,"line":618},[243,2462,1106],{"class":255},[243,2464,2465],{"class":259}," grant ",[243,2467,2468],{"class":255},"is",[243,2470,919],{"class":394},[243,2472,2473],{"class":255}," or",[243,2475,2476],{"class":259}," grant.status ",[243,2478,1348],{"class":255},[243,2480,2481],{"class":365}," \"active\"",[243,2483,587],{"class":259},[243,2485,2486,2488,2491,2493,2495,2497,2500,2502,2505,2507,2509,2511,2513],{"class":149,"line":632},[243,2487,1133],{"class":255},[243,2489,2490],{"class":259}," GrantRevoked(",[243,2492,834],{"class":255},[243,2494,837],{"class":365},[243,2496,846],{"class":394},[243,2498,2499],{"class":259},"provider",[243,2501,852],{"class":394},[243,2503,2504],{"class":365}," not connected for ",[243,2506,846],{"class":394},[243,2508,1392],{"class":259},[243,2510,852],{"class":394},[243,2512,837],{"class":365},[243,2514,412],{"class":259},[243,2516,2517],{"class":149,"line":647},[243,2518,305],{"emptyLinePlaceholder":304},[243,2520,2521,2524,2526,2529,2532,2534,2536,2538,2540],{"class":149,"line":663},[243,2522,2523],{"class":259},"    deadline ",[243,2525,353],{"class":255},[243,2527,2528],{"class":259}," grant.expires_at ",[243,2530,2531],{"class":255},"-",[243,2533,2054],{"class":259},[243,2535,2057],{"class":359},[243,2537,353],{"class":255},[243,2539,2323],{"class":394},[243,2541,412],{"class":259},[243,2543,2544,2546,2549,2552],{"class":149,"line":674},[243,2545,1106],{"class":255},[243,2547,2548],{"class":259}," deadline ",[243,2550,2551],{"class":255},">",[243,2553,2554],{"class":259}," datetime.now(timezone.utc):\n",[243,2556,2557,2560,2562],{"class":149,"line":690},[243,2558,2559],{"class":255},"        return",[243,2561,1963],{"class":394},[243,2563,2564],{"class":259},".decrypt(grant.access_token_enc).decode()\n",[243,2566,2567],{"class":149,"line":707},[243,2568,305],{"emptyLinePlaceholder":304},[243,2570,2571,2573,2576,2578,2580],{"class":149,"line":722},[243,2572,1106],{"class":255},[243,2574,2575],{"class":259}," grant.refresh_token_enc ",[243,2577,2468],{"class":255},[243,2579,919],{"class":394},[243,2581,587],{"class":259},[243,2583,2584,2587,2589],{"class":149,"line":727},[243,2585,2586],{"class":259},"        grant.status ",[243,2588,353],{"class":255},[243,2590,2591],{"class":365}," \"revoked\"\n",[243,2593,2594,2597],{"class":149,"line":738},[243,2595,2596],{"class":255},"        await",[243,2598,2599],{"class":259}," session.commit()\n",[243,2601,2602,2604,2606,2609],{"class":149,"line":753},[243,2603,1133],{"class":255},[243,2605,2490],{"class":259},[243,2607,2608],{"class":365},"\"no refresh token stored\"",[243,2610,412],{"class":259},[243,2612,2613],{"class":149,"line":765},[243,2614,305],{"emptyLinePlaceholder":304},[243,2616,2617,2619,2621],{"class":149,"line":777},[243,2618,1193],{"class":259},[243,2620,353],{"class":255},[243,2622,735],{"class":259},[243,2624,2625,2627,2629,2631],{"class":149,"line":789},[243,2626,1202],{"class":365},[243,2628,744],{"class":259},[243,2630,1989],{"class":365},[243,2632,750],{"class":259},[243,2634,2635,2638,2640,2642],{"class":149,"line":798},[243,2636,2637],{"class":365},"        \"refresh_token\"",[243,2639,744],{"class":259},[243,2641,1665],{"class":394},[243,2643,2644],{"class":259},".decrypt(grant.refresh_token_enc).decode(),\n",[243,2646,2647,2649,2651,2653],{"class":149,"line":807},[243,2648,756],{"class":365},[243,2650,744],{"class":259},[243,2652,437],{"class":394},[243,2654,750],{"class":259},[243,2656,2657,2659,2661,2663],{"class":149,"line":820},[243,2658,1242],{"class":365},[243,2660,744],{"class":259},[243,2662,456],{"class":394},[243,2664,750],{"class":259},[243,2666,2667],{"class":149,"line":826},[243,2668,823],{"class":259},[243,2670,2672,2674,2676,2678,2680,2682,2684,2686,2688],{"class":149,"line":2671},46,[243,2673,1265],{"class":255},[243,2675,1268],{"class":255},[243,2677,1271],{"class":259},[243,2679,1274],{"class":359},[243,2681,353],{"class":255},[243,2683,533],{"class":394},[243,2685,1281],{"class":259},[243,2687,1284],{"class":255},[243,2689,1287],{"class":259},[243,2691,2693,2695,2697,2699,2702,2704,2706,2708,2710],{"class":149,"line":2692},47,[243,2694,1292],{"class":259},[243,2696,353],{"class":255},[243,2698,1297],{"class":255},[243,2700,2701],{"class":259}," client.post(",[243,2703,418],{"class":394},[243,2705,369],{"class":259},[243,2707,1310],{"class":359},[243,2709,353],{"class":255},[243,2711,2712],{"class":259},"payload)\n",[243,2714,2716],{"class":149,"line":2715},48,[243,2717,305],{"emptyLinePlaceholder":304},[243,2719,2721,2723],{"class":149,"line":2720},49,[243,2722,969],{"class":255},[243,2724,2725],{"class":259}," response.status_code:\n",[243,2727,2729,2731,2733],{"class":149,"line":2728},50,[243,2730,977],{"class":255},[243,2732,1351],{"class":394},[243,2734,587],{"class":259},[243,2736,2738],{"class":149,"line":2737},51,[243,2739,2740],{"class":259},"            grant.set_tokens(response.json())\n",[243,2742,2744,2747],{"class":149,"line":2743},52,[243,2745,2746],{"class":255},"            await",[243,2748,2599],{"class":259},[243,2750,2752,2755,2757],{"class":149,"line":2751},53,[243,2753,2754],{"class":255},"            return",[243,2756,1963],{"class":394},[243,2758,2564],{"class":259},[243,2760,2762,2764,2767,2769,2772],{"class":149,"line":2761},54,[243,2763,977],{"class":255},[243,2765,2766],{"class":394}," 400",[243,2768,916],{"class":255},[243,2770,2771],{"class":394}," 401",[243,2773,587],{"class":259},[243,2775,2777,2780,2782],{"class":149,"line":2776},55,[243,2778,2779],{"class":259},"            grant.status ",[243,2781,353],{"class":255},[243,2783,2591],{"class":365},[243,2785,2787,2789],{"class":149,"line":2786},56,[243,2788,2746],{"class":255},[243,2790,2599],{"class":259},[243,2792,2794,2796,2799,2802,2804,2807],{"class":149,"line":2793},57,[243,2795,1000],{"class":255},[243,2797,2798],{"class":259}," GrantRevoked(response.json().get(",[243,2800,2801],{"class":365},"\"error\"",[243,2803,369],{"class":259},[243,2805,2806],{"class":365},"\"invalid_grant\"",[243,2808,552],{"class":259},[243,2810,2812,2814],{"class":149,"line":2811},58,[243,2813,977],{"class":255},[243,2815,1028],{"class":259},[243,2817,2819],{"class":149,"line":2818},59,[243,2820,2821],{"class":259},"            response.raise_for_status()\n",[243,2823,2825,2827,2829,2832],{"class":149,"line":2824},60,[243,2826,1000],{"class":255},[243,2828,2490],{"class":259},[243,2830,2831],{"class":365},"\"unreachable\"",[243,2833,412],{"class":259},[14,2835,2836,2837,2841,2842,2845],{},"A 5xx from the token endpoint should be retried rather than treated as revocation — wrap the call with ",[23,2838,2840],{"href":2839},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fretrying-failed-http-requests-with-tenacity\u002F","tenacity-based retries"," and an exponential backoff. Only ",[53,2843,2844],{},"invalid_grant"," on a 400 is a permanent verdict.",[42,2847],{},[45,2849,2851],{"id":2850},"handling-revoked-grants-in-a-background-job","Handling revoked grants in a background job",[14,2853,2854,2855,2859,2860,2864],{},"Grants die quietly. A customer removes your app from their provider's security page, an admin rotates the workspace, a password change invalidates every session — and your product finds out hours later when a sync fails. Fix that with a scheduled sweep that touches every grant inside its skew window and downgrades anything the provider rejects. Run it on a worker, not in the request path; the ",[23,2856,2858],{"href":2857},"\u002Fautomating-side-hustle-operations-with-apis\u002Fscheduling-data-pipelines-with-cron\u002Fapscheduler-vs-celery-beat\u002F","APScheduler versus Celery beat trade-off"," and the broader ",[23,2861,2863],{"href":2862},"\u002Fscaling-and-operating-production-python-apis\u002Frunning-background-jobs-with-celery\u002Fcelery-vs-rq-vs-arq\u002F","Celery, RQ and arq comparison"," both apply. For a job this small, arq on the Redis you already pay for is the cheapest option.",[61,2866,70,2870,70,2873,70,2876,70,2878,70,2885,70,2890,70,2893,70,2897,70,2900,70,2904,70,2906,70,2910,70,2913,70,2915,70,2919,70,2922,70,2926,70,2931,70,2935,70,2938,70,2941,70,2945,70,2950,70,2953,70,2959,70,2962],{"viewBox":1456,"role":64,"ariaLabelledBy":2867,"xmlns":68,"style":69},[2868,2869],"oa2g-t","oa2g-d",[72,2871,2872],{"id":2868},"Grant lifecycle state machine",[76,2874,2875],{"id":2869},"State machine showing an active grant moving into refreshing when under 300 seconds remain, returning to active on success, moving to revoked on an invalid_grant response, then prompting the customer to reconnect which restores the active state.",[80,2877],{"x":82,"y":82,"width":1468,"height":1469,"fill":85},[87,2879,89,2880,70],{},[91,2881,99,2883,89],{"id":2882,"viewBox":94,"refX":95,"refY":96,"markerWidth":97,"markerHeight":97,"orient":98},"oa2g-arrow",[101,2884],{"d":103,"fill":104},[115,2886,2889],{"x":2887,"y":2888,"fill":119,"style":164},"214","98","under 300 s left",[115,2891,2844],{"x":2892,"y":2888,"fill":119,"style":164},"424",[80,2894],{"x":2895,"y":2896,"width":1567,"height":1553,"rx":1482,"fill":85,"stroke":134,"style":126},"40","120",[115,2898,2899],{"x":1557,"y":2180,"fill":119,"style":120},"active",[115,2901,2903],{"x":1557,"y":2902,"fill":104,"style":164},"164","tokens fresh",[80,2905],{"x":200,"y":2896,"width":1567,"height":1553,"rx":1482,"fill":85,"stroke":125,"style":126},[115,2907,2909],{"x":2908,"y":2180,"fill":119,"style":120},"320","refreshing",[115,2911,2912],{"x":2908,"y":2902,"fill":104,"style":164},"row lock held",[80,2914],{"x":1539,"y":2896,"width":1567,"height":1553,"rx":1482,"fill":85,"stroke":143,"style":126},[115,2916,2918],{"x":2917,"y":2180,"fill":119,"style":120},"530","revoked",[115,2920,2921],{"x":2917,"y":2902,"fill":104,"style":164},"syncs paused",[80,2923],{"x":2924,"y":1481,"width":124,"height":2925,"rx":1482,"fill":85,"stroke":1571,"style":126},"450","58",[115,2927,2930],{"x":2928,"y":2929,"fill":119,"style":120},"555","246","reconnect prompt",[115,2932,2934],{"x":2928,"y":2933,"fill":104,"style":164},"265","email plus in-app banner",[149,2936],{"x1":2144,"y1":109,"x2":2929,"y2":109,"stroke":104,"style":2937},"stroke-width:2;marker-end:url(#oa2g-arrow);",[149,2939],{"x1":2940,"y1":109,"x2":1552,"y2":109,"stroke":143,"style":2937},"390",[101,2942],{"d":2943,"fill":2944,"stroke":134,"style":2937},"M320 180 L320 205 L110 205 L110 184","none",[115,2946,2949],{"x":2947,"y":2948,"fill":119,"style":164},"215","223","200 OK, tokens rotated",[149,2951],{"x1":2917,"y1":2144,"x2":2917,"y2":2952,"stroke":104,"style":2937},"218",[115,2954,2958],{"x":2955,"y":2956,"fill":119,"style":2957},"545","205","font-size:11;font-family:var(--font-sans);","notify",[101,2960],{"d":2961,"fill":2944,"stroke":1571,"style":2937},"M450 251 L20 251 L20 150 L36 150",[115,2963,2966],{"x":2964,"y":2965,"fill":119,"style":164},"235","270","customer re-approves the scopes",[234,2968,2970],{"className":236,"code":2969,"language":238,"meta":239,"style":239},"# app\u002Fjobs.py\nimport os\n\nfrom arq import cron\nfrom arq.connections import RedisSettings\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine\n\nfrom app.models import OAuthGrant\nfrom app.tokens import GrantRevoked, valid_access_token\n\nengine = create_async_engine(os.getenv(\"DATABASE_URL\", \"\"), pool_pre_ping=True)\nSession = async_sessionmaker(engine, expire_on_commit=False)\nBATCH = int(os.getenv(\"OAUTH_SWEEP_BATCH\", \"200\"))\n\n\nasync def sweep_grants(ctx) -> dict[str, int]:\n    refreshed = revoked = 0\n    async with Session() as session:\n        ids = (\n            await session.scalars(\n                select(OAuthGrant.id)\n                .where(OAuthGrant.status == \"active\")\n                .order_by(OAuthGrant.expires_at)\n                .limit(BATCH)\n            )\n        ).all()\n\n    for grant_id in ids:\n        async with Session.begin() as session:\n            grant = await session.get(OAuthGrant, grant_id)\n            try:\n                await valid_access_token(session, grant.user_id, grant.provider)\n                refreshed += 1\n            except GrantRevoked:\n                revoked += 1\n                await ctx[\"redis\"].enqueue_job(\n                    \"notify_reconnect\", grant.user_id, grant.provider\n                )\n    return {\"refreshed\": refreshed, \"revoked\": revoked}\n\n\nclass WorkerSettings:\n    redis_settings = RedisSettings.from_dsn(os.getenv(\"REDIS_URL\", \"redis:\u002F\u002Flocalhost\"))\n    cron_jobs = [cron(sweep_grants, minute={0, 15, 30, 45})]\n    functions = [sweep_grants]\n",[53,2971,2972,2977,2983,2987,2999,3011,3021,3032,3036,3047,3059,3063,3091,3111,3132,3136,3140,3161,3176,3190,3199,3206,3211,3222,3227,3236,3241,3246,3250,3264,3278,3290,3297,3305,3316,3324,3333,3346,3354,3359,3378,3382,3386,3395,3415,3449],{"__ignoreMap":239},[243,2973,2974],{"class":149,"line":245},[243,2975,2976],{"class":248},"# app\u002Fjobs.py\n",[243,2978,2979,2981],{"class":149,"line":252},[243,2980,256],{"class":255},[243,2982,276],{"class":259},[243,2984,2985],{"class":149,"line":263},[243,2986,305],{"emptyLinePlaceholder":304},[243,2988,2989,2991,2994,2996],{"class":149,"line":271},[243,2990,290],{"class":255},[243,2992,2993],{"class":259}," arq ",[243,2995,256],{"class":255},[243,2997,2998],{"class":259}," cron\n",[243,3000,3001,3003,3006,3008],{"class":149,"line":279},[243,3002,290],{"class":255},[243,3004,3005],{"class":259}," arq.connections ",[243,3007,256],{"class":255},[243,3009,3010],{"class":259}," RedisSettings\n",[243,3012,3013,3015,3017,3019],{"class":149,"line":287},[243,3014,290],{"class":255},[243,3016,1639],{"class":259},[243,3018,256],{"class":255},[243,3020,2259],{"class":259},[243,3022,3023,3025,3027,3029],{"class":149,"line":301},[243,3024,290],{"class":255},[243,3026,2266],{"class":259},[243,3028,256],{"class":255},[243,3030,3031],{"class":259}," async_sessionmaker, create_async_engine\n",[243,3033,3034],{"class":149,"line":308},[243,3035,305],{"emptyLinePlaceholder":304},[243,3037,3038,3040,3042,3044],{"class":149,"line":316},[243,3039,290],{"class":255},[243,3041,2282],{"class":259},[243,3043,256],{"class":255},[243,3045,3046],{"class":259}," OAuthGrant\n",[243,3048,3049,3051,3054,3056],{"class":149,"line":329},[243,3050,290],{"class":255},[243,3052,3053],{"class":259}," app.tokens ",[243,3055,256],{"class":255},[243,3057,3058],{"class":259}," GrantRevoked, valid_access_token\n",[243,3060,3061],{"class":149,"line":342},[243,3062,305],{"emptyLinePlaceholder":304},[243,3064,3065,3068,3070,3073,3076,3078,3080,3082,3085,3087,3089],{"class":149,"line":347},[243,3066,3067],{"class":259},"engine ",[243,3069,353],{"class":255},[243,3071,3072],{"class":259}," create_async_engine(os.getenv(",[243,3074,3075],{"class":365},"\"DATABASE_URL\"",[243,3077,369],{"class":259},[243,3079,409],{"class":365},[243,3081,1818],{"class":259},[243,3083,3084],{"class":359},"pool_pre_ping",[243,3086,353],{"class":255},[243,3088,1797],{"class":394},[243,3090,412],{"class":259},[243,3092,3093,3096,3098,3101,3104,3106,3109],{"class":149,"line":386},[243,3094,3095],{"class":259},"Session ",[243,3097,353],{"class":255},[243,3099,3100],{"class":259}," async_sessionmaker(engine, ",[243,3102,3103],{"class":359},"expire_on_commit",[243,3105,353],{"class":255},[243,3107,3108],{"class":394},"False",[243,3110,412],{"class":259},[243,3112,3113,3116,3118,3120,3122,3125,3127,3130],{"class":149,"line":391},[243,3114,3115],{"class":394},"BATCH",[243,3117,398],{"class":255},[243,3119,2016],{"class":394},[243,3121,541],{"class":259},[243,3123,3124],{"class":365},"\"OAUTH_SWEEP_BATCH\"",[243,3126,369],{"class":259},[243,3128,3129],{"class":365},"\"200\"",[243,3131,552],{"class":259},[243,3133,3134],{"class":149,"line":415},[243,3135,305],{"emptyLinePlaceholder":304},[243,3137,3138],{"class":149,"line":434},[243,3139,305],{"emptyLinePlaceholder":304},[243,3141,3142,3144,3146,3149,3152,3154,3156,3158],{"class":149,"line":453},[243,3143,635],{"class":255},[243,3145,638],{"class":255},[243,3147,3148],{"class":571}," sweep_grants",[243,3150,3151],{"class":259},"(ctx) -> dict[",[243,3153,584],{"class":394},[243,3155,369],{"class":259},[243,3157,1782],{"class":394},[243,3159,3160],{"class":259},"]:\n",[243,3162,3163,3166,3168,3171,3173],{"class":149,"line":472},[243,3164,3165],{"class":259},"    refreshed ",[243,3167,353],{"class":255},[243,3169,3170],{"class":259}," revoked ",[243,3172,353],{"class":255},[243,3174,3175],{"class":394}," 0\n",[243,3177,3178,3180,3182,3185,3187],{"class":149,"line":491},[243,3179,1265],{"class":255},[243,3181,1268],{"class":255},[243,3183,3184],{"class":259}," Session() ",[243,3186,1284],{"class":255},[243,3188,3189],{"class":259}," session:\n",[243,3191,3192,3195,3197],{"class":149,"line":510},[243,3193,3194],{"class":259},"        ids ",[243,3196,353],{"class":255},[243,3198,2416],{"class":259},[243,3200,3201,3203],{"class":149,"line":530},[243,3202,2746],{"class":255},[243,3204,3205],{"class":259}," session.scalars(\n",[243,3207,3208],{"class":149,"line":555},[243,3209,3210],{"class":259},"                select(OAuthGrant.id)\n",[243,3212,3213,3216,3218,3220],{"class":149,"line":560},[243,3214,3215],{"class":259},"                .where(OAuthGrant.status ",[243,3217,872],{"class":255},[243,3219,2481],{"class":365},[243,3221,412],{"class":259},[243,3223,3224],{"class":149,"line":565},[243,3225,3226],{"class":259},"                .order_by(OAuthGrant.expires_at)\n",[243,3228,3229,3232,3234],{"class":149,"line":590},[243,3230,3231],{"class":259},"                .limit(",[243,3233,3115],{"class":394},[243,3235,412],{"class":259},[243,3237,3238],{"class":149,"line":608},[243,3239,3240],{"class":259},"            )\n",[243,3242,3243],{"class":149,"line":613},[243,3244,3245],{"class":259},"        ).all()\n",[243,3247,3248],{"class":149,"line":618},[243,3249,305],{"emptyLinePlaceholder":304},[243,3251,3252,3255,3258,3261],{"class":149,"line":632},[243,3253,3254],{"class":255},"    for",[243,3256,3257],{"class":259}," grant_id ",[243,3259,3260],{"class":255},"in",[243,3262,3263],{"class":259}," ids:\n",[243,3265,3266,3269,3271,3274,3276],{"class":149,"line":647},[243,3267,3268],{"class":255},"        async",[243,3270,1268],{"class":255},[243,3272,3273],{"class":259}," Session.begin() ",[243,3275,1284],{"class":255},[243,3277,3189],{"class":259},[243,3279,3280,3283,3285,3287],{"class":149,"line":663},[243,3281,3282],{"class":259},"            grant ",[243,3284,353],{"class":255},[243,3286,1297],{"class":255},[243,3288,3289],{"class":259}," session.get(OAuthGrant, grant_id)\n",[243,3291,3292,3295],{"class":149,"line":674},[243,3293,3294],{"class":255},"            try",[243,3296,587],{"class":259},[243,3298,3299,3302],{"class":149,"line":690},[243,3300,3301],{"class":255},"                await",[243,3303,3304],{"class":259}," valid_access_token(session, grant.user_id, grant.provider)\n",[243,3306,3307,3310,3313],{"class":149,"line":707},[243,3308,3309],{"class":259},"                refreshed ",[243,3311,3312],{"class":255},"+=",[243,3314,3315],{"class":394}," 1\n",[243,3317,3318,3321],{"class":149,"line":722},[243,3319,3320],{"class":255},"            except",[243,3322,3323],{"class":259}," GrantRevoked:\n",[243,3325,3326,3329,3331],{"class":149,"line":727},[243,3327,3328],{"class":259},"                revoked ",[243,3330,3312],{"class":255},[243,3332,3315],{"class":394},[243,3334,3335,3337,3340,3343],{"class":149,"line":738},[243,3336,3301],{"class":255},[243,3338,3339],{"class":259}," ctx[",[243,3341,3342],{"class":365},"\"redis\"",[243,3344,3345],{"class":259},"].enqueue_job(\n",[243,3347,3348,3351],{"class":149,"line":753},[243,3349,3350],{"class":365},"                    \"notify_reconnect\"",[243,3352,3353],{"class":259},", grant.user_id, grant.provider\n",[243,3355,3356],{"class":149,"line":765},[243,3357,3358],{"class":259},"                )\n",[243,3360,3361,3363,3366,3369,3372,3375],{"class":149,"line":777},[243,3362,593],{"class":255},[243,3364,3365],{"class":259}," {",[243,3367,3368],{"class":365},"\"refreshed\"",[243,3370,3371],{"class":259},": refreshed, ",[243,3373,3374],{"class":365},"\"revoked\"",[243,3376,3377],{"class":259},": revoked}\n",[243,3379,3380],{"class":149,"line":789},[243,3381,305],{"emptyLinePlaceholder":304},[243,3383,3384],{"class":149,"line":798},[243,3385,305],{"emptyLinePlaceholder":304},[243,3387,3388,3390,3393],{"class":149,"line":807},[243,3389,1689],{"class":255},[243,3391,3392],{"class":571}," WorkerSettings",[243,3394,587],{"class":259},[243,3396,3397,3400,3402,3405,3408,3410,3413],{"class":149,"line":820},[243,3398,3399],{"class":259},"    redis_settings ",[243,3401,353],{"class":255},[243,3403,3404],{"class":259}," RedisSettings.from_dsn(os.getenv(",[243,3406,3407],{"class":365},"\"REDIS_URL\"",[243,3409,369],{"class":259},[243,3411,3412],{"class":365},"\"redis:\u002F\u002Flocalhost\"",[243,3414,552],{"class":259},[243,3416,3417,3420,3422,3425,3428,3430,3432,3434,3436,3438,3440,3442,3444,3446],{"class":149,"line":826},[243,3418,3419],{"class":259},"    cron_jobs ",[243,3421,353],{"class":255},[243,3423,3424],{"class":259}," [cron(sweep_grants, ",[243,3426,3427],{"class":359},"minute",[243,3429,353],{"class":255},[243,3431,846],{"class":259},[243,3433,82],{"class":394},[243,3435,369],{"class":259},[243,3437,108],{"class":394},[243,3439,369],{"class":259},[243,3441,1479],{"class":394},[243,3443,369],{"class":259},[243,3445,1488],{"class":394},[243,3447,3448],{"class":259},"})]\n",[243,3450,3451,3454,3456],{"class":149,"line":2671},[243,3452,3453],{"class":259},"    functions ",[243,3455,353],{"class":255},[243,3457,3458],{"class":259}," [sweep_grants]\n",[14,3460,3461,3462,3466],{},"Emit the returned counters through your logger so a spike in revocations shows up on a dashboard — ",[23,3463,3465],{"href":3464},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","structured logging with structlog"," makes that a one-line change. A sudden climb in the revoked count is usually a provider policy change, not customer churn, and you want to know within an hour.",[42,3468],{},[45,3470,3472],{"id":3471},"when-to-choose-this-flow-and-when-not-to","When to choose this flow, and when not to",[14,3474,3475],{},"The authorization code flow is the right default whenever a human must approve access to their own account. It is the wrong tool when no human is involved.",[3477,3478,3479,3495],"table",{},[3480,3481,3482],"thead",{},[3483,3484,3485,3489,3492],"tr",{},[3486,3487,3488],"th",{},"Flow",[3486,3490,3491],{},"Choose it when",[3486,3493,3494],{},"Skip it when",[3496,3497,3498,3510,3521,3532],"tbody",{},[3483,3499,3500,3504,3507],{},[3501,3502,3503],"td",{},"Code + PKCE",[3501,3505,3506],{},"A customer approves access to their account",[3501,3508,3509],{},"No end user is present",[3483,3511,3512,3515,3518],{},[3501,3513,3514],{},"Client credentials",[3501,3516,3517],{},"Your server owns the data",[3501,3519,3520],{},"Per-user scopes matter",[3483,3522,3523,3526,3529],{},[3501,3524,3525],{},"Device code",[3501,3527,3528],{},"CLI or TV with no browser",[3501,3530,3531],{},"You control the browser",[3483,3533,3534,3537,3540],{},[3501,3535,3536],{},"Implicit",[3501,3538,3539],{},"Never",[3501,3541,3542],{},"Always — it is deprecated",[14,3544,3545],{},"Scope selection is the other half of this decision, and it is commercial rather than technical. Ask for the narrowest scope set that makes your feature work, because an over-broad consent screen kills conversion at exactly the moment a trial user is deciding whether to trust you. Requesting write access to a customer's entire mailbox when you only need to read message headers doubles the drop-off on that screen in my experience, and it also drags you into a security review at any company larger than about fifty people. Start narrow, then trigger a second authorization round only when the customer opts into a feature that genuinely needs more.",[14,3547,3548,3549,3552],{},"Cost matters here too. Each connected customer costs you one refresh call per hour, or roughly 720 calls a month — negligible against any provider quota, but not free if you are on a metered plan. Sweeping every fifteen minutes across ten thousand grants means four database scans an hour and about 10,000 outbound refreshes a day, which one small worker handles comfortably. If you are still at a few hundred customers, skip the sweep entirely and refresh lazily inside ",[53,3550,3551],{},"valid_access_token","; add the job when a support ticket about a silently broken integration costs more than the worker.",[42,3554],{},[45,3556,3558],{"id":3557},"migrating-from-stored-credentials-or-long-lived-tokens","Migrating from stored credentials or long-lived tokens",[14,3560,3561],{},"If your v1 shipped with customers pasting a personal access token into a settings field, move to OAuth2 in four steps and do not force a hard cutover.",[3563,3564,3565,3572,3575,3578],"ol",{},[3566,3567,3568,3569,3571],"li",{},"Add the ",[53,3570,1490],{}," table and both routes behind a feature flag. Existing token rows keep working untouched.",[3566,3573,3574],{},"Change your provider client to resolve a credential through one function that tries an OAuth grant first, then falls back to the legacy stored token.",[3566,3576,3577],{},"Show a \"Reconnect with one click\" banner to legacy customers. Most convert within two weeks because the flow is genuinely easier than copying a token.",[3566,3579,3580,3581,40],{},"Set a deadline, email the stragglers twice, then delete the legacy column. The same staged approach works for your own credentials — see ",[23,3582,3584],{"href":3583},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F","rotating API keys without downtime",[42,3586],{},[45,3588,3590],{"id":3589},"builder-verdict","Builder verdict",[14,3592,3593,3594,3597],{},"Implement the authorization code flow with PKCE and skip every library that promises to do it for you. The whole client is about 150 lines of code you fully understand, and the alternatives — ",[53,3595,3596],{},"authlib"," integrations, provider-specific SDKs, hosted connector platforms at forty dollars per connected account per month — all hide the two things that actually break in production: refresh concurrency and revocation detection. You will debug those anyway, so own them. The winner on shipping velocity is the code above plus a fifteen-minute sweep job: one afternoon to build, near-zero marginal cost per customer, and it turns a silent integration failure into an email your customer can act on. Handwritten beats hosted here, and it is not close.",[45,3599,3601],{"id":3600},"faq","FAQ",[14,3603,3604,3608],{},[3605,3606,3607],"strong",{},"What does running the OAuth2 client cost per connected customer?","\nAbout 720 refresh calls and 720 small database transactions a month, which rounds to nothing on any managed Postgres and Redis pair. The real cost is the worker: one 512 MB instance at roughly seven dollars a month handles tens of thousands of grants. Hosted connector platforms charge per connected account, so break-even against building it yourself arrives at about fifty customers.",[14,3610,3611,3614,3615,3618],{},[3605,3612,3613],{},"How do I rotate the encryption key protecting stored refresh tokens?","\nUse Fernet's ",[53,3616,3617],{},"MultiFernet"," with the new key first and the old key second, deploy, then run a background pass that decrypts and re-encrypts every row. Drop the old key from the list on the next deploy. Because tokens live behind one column, this never requires customers to reconnect and takes minutes even at scale.",[14,3620,3621,3624],{},[3605,3622,3623],{},"Will migrating from stored personal access tokens break existing customers?","\nNot if you keep the fallback path alive during the transition. Resolve credentials through a single function that prefers an OAuth grant and falls back to the legacy column, then delete the column only after the last customer converts. The risk lives entirely in hard cutovers.",[14,3626,3627,3630],{},[3605,3628,3629],{},"How many revoked grants should I expect in normal operation?","\nBetween one and three percent of connected accounts per month for mature providers, mostly from staff changes and password resets rather than churn. Track that rate as its own metric: if it doubles in a week, the provider changed a policy or shortened a refresh token lifetime, and your reconnect emails need to go out before support tickets do.",[14,3632,3633,3636],{},[3605,3634,3635],{},"Do I still need PKCE if my client secret never leaves the server?","\nYes. PKCE costs fifteen lines and defends against authorization code interception during the browser redirect, which a server-held secret does nothing about. Several providers now reject authorization requests without a code challenge, so implementing it also removes a future migration.",[45,3638,3640],{"id":3639},"related","Related",[3642,3643,3644,3649,3656,3662,3669],"ul",{},[3566,3645,3646,3648],{},[23,3647,26],{"href":25}," — the parent guide covering every credential type your API touches.",[3566,3650,3651,3655],{},[23,3652,3654],{"href":3653},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fjwt-vs-api-keys-for-python-apis\u002F","JWT vs API Keys for Python APIs"," — pick the credential you issue to your own callers, once you can consume someone else's.",[3566,3657,3658,3661],{},[23,3659,3660],{"href":3583},"Rotating API Keys Without Downtime"," — the same staged-cutover pattern applied to secrets you own.",[3566,3663,3664,3668],{},[23,3665,3667],{"href":3666},"\u002Fautomating-side-hustle-operations-with-apis\u002Fconnecting-crm-email-apis\u002Fautomate-gmail-with-python-and-gmail-api\u002F","Automate Gmail with Python and the Gmail API"," — a concrete provider that exercises every part of this flow.",[3566,3670,3671,3674],{},[23,3672,3673],{"href":2106},"Debugging 401 Unauthorized API Errors"," — what to check when a token you believe is valid gets rejected.",[3676,3677,3678],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":239,"searchDepth":252,"depth":252,"links":3680},[3681,3682,3683,3684,3685,3686,3687,3688,3689],{"id":47,"depth":252,"text":48},{"id":1449,"depth":252,"text":1450},{"id":2099,"depth":252,"text":2100},{"id":2850,"depth":252,"text":2851},{"id":3471,"depth":252,"text":3472},{"id":3557,"depth":252,"text":3558},{"id":3589,"depth":252,"text":3590},{"id":3600,"depth":252,"text":3601},{"id":3639,"depth":252,"text":3640},"Implement the OAuth2 authorization code flow in FastAPI as a third-party client: PKCE, state validation, encrypted token storage, refresh, and revoked grants.","md",{"pageTitle":3693,"type":3694,"datePublished":3695,"dateModified":3695},"OAuth2 Authorization Code Flow in FastAPI: PKCE + Refresh","article","2026-07-23","\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fimplementing-oauth2-authorization-code-flow-in-fastapi",{"title":5,"description":3690},"getting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fimplementing-oauth2-authorization-code-flow-in-fastapi\u002Findex","7fhOuIieRMdGy5LJTci4utsAAgJ_Cg5whT8id9tyxj8",{"@context":3701,"@type":3702,"mainEntity":3703},"https:\u002F\u002Fschema.org","FAQPage",[3704,3709,3712,3715,3718],{"@type":3705,"name":3607,"acceptedAnswer":3706},"Question",{"@type":3707,"text":3708},"Answer","About 720 refresh calls and 720 small database transactions a month, which rounds to nothing on any managed Postgres and Redis pair. The real cost is the worker: one 512 MB instance at roughly seven dollars a month handles tens of thousands of grants. Hosted connector platforms charge per connected account, so break-even against building it yourself arrives at about fifty customers.",{"@type":3705,"name":3613,"acceptedAnswer":3710},{"@type":3707,"text":3711},"Use Fernet's MultiFernet with the new key first and the old key second, deploy, then run a background pass that decrypts and re-encrypts every row. Drop the old key from the list on the next deploy. Because tokens live behind one column, this never requires customers to reconnect and takes minutes even at scale.",{"@type":3705,"name":3623,"acceptedAnswer":3713},{"@type":3707,"text":3714},"Not if you keep the fallback path alive during the transition. Resolve credentials through a single function that prefers an OAuth grant and falls back to the legacy column, then delete the column only after the last customer converts. The risk lives entirely in hard cutovers.",{"@type":3705,"name":3629,"acceptedAnswer":3716},{"@type":3707,"text":3717},"Between one and three percent of connected accounts per month for mature providers, mostly from staff changes and password resets rather than churn. Track that rate as its own metric: if it doubles in a week, the provider changed a policy or shortened a refresh token lifetime, and your reconnect emails need to go out before support tickets do.",{"@type":3705,"name":3635,"acceptedAnswer":3719},{"@type":3707,"text":3720},"Yes. PKCE costs fifteen lines and defends against authorization code interception during the browser redirect, which a server-held secret does nothing about. Several providers now reject authorization requests without a code challenge, so implementing it also removes a future migration.",1784887028458]