[{"data":1,"prerenderedAt":512},["ShallowReactive",2],{"blog-how-to/rotate-database-credentials":3},{"id":4,"title":5,"body":6,"category":486,"date":487,"dateModified":487,"description":488,"draft":489,"extension":490,"faq":491,"featured":489,"headerVariant":497,"image":498,"keywords":499,"meta":500,"navigation":82,"ogDescription":501,"ogTitle":498,"path":502,"readTime":503,"schemaOrg":504,"schemaType":505,"seo":506,"sitemap":507,"stem":508,"tags":509,"twitterCard":510,"__hash__":511},"blog/blog/how-to/rotate-database-credentials.md","How to Rotate Database Credentials Without Downtime (2026)",{"type":7,"value":8,"toc":477},"minimark",[9,13,16,22,27,30,33,37,96,119,128,137,141,144,180,183,189,193,196,202,287,290,296,300,303,332,335,339,379,382,386,389,409,412,446,465,473],[10,11,12],"p",{},"Change a database password in place and every server, cron job, and background worker still holding the old value starts failing authentication the instant you save it, not gracefully, not with a retry, just an outage that starts exactly when you thought you were being responsible about security.",[10,14,15],{},"The fix isn't avoiding rotation. It's rotating with two valid credentials at once instead of one.",[17,18,19],"tldr",{},[10,20,21],{},"Don't change a database password in place. Create a second credential (a new role, or a second password if your database supports it), migrate every consumer to it one at a time, confirm nothing still uses the old one, then revoke the old credential last. The most common way this still breaks: connection poolers like PgBouncer cache authenticated connections and need an explicit reload, not just an app redeploy.",[23,24,26],"h2",{"id":25},"why-the-obvious-way-breaks-things","Why the Obvious Way Breaks Things",[10,28,29],{},"The instinct is: go to your database dashboard, click \"reset password,\" paste the new value into your environment variables, redeploy. That works for exactly one service. If you have a second server, a cron job, a background worker, or a serverless function still holding the old value in a cached environment, it starts failing the moment the old password stops working, which is immediately.",[10,31,32],{},"Rotation should never have a moment where the old credential is dead and the new one isn't confirmed live everywhere. That means running both at once, briefly, on purpose.",[23,34,36],{"id":35},"step-1-create-a-new-credential-alongside-the-old-one","Step 1: Create a New Credential Alongside the Old One",[38,39,41,47],"step",{"number":40},"1",[10,42,43],{},[44,45,46],"strong",{},"Postgres: create a second role with the same grants.",[48,49,54],"pre",{"className":50,"code":51,"language":52,"meta":53,"style":53},"language-sql shiki shiki-themes github-light github-dark","-- Create a new role that mirrors the old one's permissions\nCREATE ROLE app_user_v2 WITH LOGIN PASSWORD 'new-strong-password-here';\nGRANT app_user_old_role TO app_user_v2; -- inherit the same role grants\n\n-- Or, if using direct table grants instead of role membership:\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user_v2;\n","sql","",[55,56,57,65,71,77,84,90],"code",{"__ignoreMap":53},[58,59,62],"span",{"class":60,"line":61},"line",1,[58,63,64],{},"-- Create a new role that mirrors the old one's permissions\n",[58,66,68],{"class":60,"line":67},2,[58,69,70],{},"CREATE ROLE app_user_v2 WITH LOGIN PASSWORD 'new-strong-password-here';\n",[58,72,74],{"class":60,"line":73},3,[58,75,76],{},"GRANT app_user_old_role TO app_user_v2; -- inherit the same role grants\n",[58,78,80],{"class":60,"line":79},4,[58,81,83],{"emptyLinePlaceholder":82},true,"\n",[58,85,87],{"class":60,"line":86},5,[58,88,89],{},"-- Or, if using direct table grants instead of role membership:\n",[58,91,93],{"class":60,"line":92},6,[58,94,95],{},"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user_v2;\n",[38,97,99,104],{"number":98},"2",[10,100,101],{},[44,102,103],{},"MySQL 8+: use dual-password support natively, no new user needed.",[48,105,107],{"className":50,"code":106,"language":52,"meta":53,"style":53},"ALTER USER 'app_user'@'%' IDENTIFIED BY 'new-password' RETAIN CURRENT PASSWORD;\n-- Both the old and new password work until you discard the old one\n",[55,108,109,114],{"__ignoreMap":53},[58,110,111],{"class":60,"line":61},[58,112,113],{},"ALTER USER 'app_user'@'%' IDENTIFIED BY 'new-password' RETAIN CURRENT PASSWORD;\n",[58,115,116],{"class":60,"line":67},[58,117,118],{},"-- Both the old and new password work until you discard the old one\n",[38,120,122],{"number":121},"3",[10,123,124,127],{},[44,125,126],{},"MongoDB Atlas: create a second database user in the Atlas UI or API",", under Database Access, with identical roles to the existing user. Atlas supports multiple simultaneous users on the same cluster with no extra configuration.",[38,129,131],{"number":130},"4",[10,132,133,136],{},[44,134,135],{},"Redis / Upstash: add a second ACL user or token"," rather than rotating the primary password, if your plan supports Redis ACLs (Redis 6+). On managed Redis without ACL support, you may need to accept a short overlap window instead; check your provider's rotation docs first.",[23,138,140],{"id":139},"step-2-migrate-every-consumer","Step 2: Migrate Every Consumer",[10,142,143],{},"Update the connection string or credential reference in each service, one at a time, and redeploy before moving to the next:",[48,145,149],{"className":146,"code":147,"language":148,"meta":53,"style":53},"language-bash shiki shiki-themes github-light github-dark","# Update the environment variable for one service\nDATABASE_URL=\"postgresql://app_user_v2:new-strong-password-here@host:5432/dbname\"\n\n# Redeploy that specific service, confirm it's healthy, then move to the next\n","bash",[55,150,151,157,171,175],{"__ignoreMap":53},[58,152,153],{"class":60,"line":61},[58,154,156],{"class":155},"sJ8bj","# Update the environment variable for one service\n",[58,158,159,163,167],{"class":60,"line":67},[58,160,162],{"class":161},"sVt8B","DATABASE_URL",[58,164,166],{"class":165},"szBVR","=",[58,168,170],{"class":169},"sZZnC","\"postgresql://app_user_v2:new-strong-password-here@host:5432/dbname\"\n",[58,172,173],{"class":60,"line":73},[58,174,83],{"emptyLinePlaceholder":82},[58,176,177],{"class":60,"line":79},[58,178,179],{"class":155},"# Redeploy that specific service, confirm it's healthy, then move to the next\n",[10,181,182],{},"Don't batch-update every service's environment variables and redeploy all at once. Rolling through them one at a time means a single bad connection string only takes down one service, not everything.",[184,185,186],"warning-box",{},[10,187,188],{},"Don't forget the services that don't look like \"the app\": scheduled jobs, database migration scripts in CI, admin scripts run manually from a laptop, and any read-replica reporting tool. These are the ones that still hold the old credential when everyone assumes rotation is done.",[23,190,192],{"id":191},"step-3-flush-the-connection-pooler","Step 3: Flush the Connection Pooler",[10,194,195],{},"This is the step almost every rotation guide skips, and it's the one that causes \"I updated everything but it's still failing.\"",[10,197,198,199,201],{},"PgBouncer, Supabase's built-in pooler, RDS Proxy, and similar tools maintain a pool of already-authenticated connections and reuse them across requests. Updating your app's ",[55,200,162],{}," doesn't close those cached connections. Requests keep flowing through connections opened under the old credential until the pooler itself is told to refresh.",[48,203,205],{"className":146,"code":204,"language":148,"meta":53,"style":53},"# PgBouncer: reload config after updating userlist.txt or auth source\npsql -h pgbouncer-host -p 6432 pgbouncer -c \"RELOAD;\"\n\n# Or, for a harder reset if RELOAD isn't sufficient:\npsql -h pgbouncer-host -p 6432 pgbouncer -c \"PAUSE;\"\npsql -h pgbouncer-host -p 6432 pgbouncer -c \"RESUME;\"\n",[55,206,207,212,240,244,249,268],{"__ignoreMap":53},[58,208,209],{"class":60,"line":61},[58,210,211],{"class":155},"# PgBouncer: reload config after updating userlist.txt or auth source\n",[58,213,214,218,222,225,228,231,234,237],{"class":60,"line":67},[58,215,217],{"class":216},"sScJk","psql",[58,219,221],{"class":220},"sj4cs"," -h",[58,223,224],{"class":169}," pgbouncer-host",[58,226,227],{"class":220}," -p",[58,229,230],{"class":220}," 6432",[58,232,233],{"class":169}," pgbouncer",[58,235,236],{"class":220}," -c",[58,238,239],{"class":169}," \"RELOAD;\"\n",[58,241,242],{"class":60,"line":73},[58,243,83],{"emptyLinePlaceholder":82},[58,245,246],{"class":60,"line":79},[58,247,248],{"class":155},"# Or, for a harder reset if RELOAD isn't sufficient:\n",[58,250,251,253,255,257,259,261,263,265],{"class":60,"line":86},[58,252,217],{"class":216},[58,254,221],{"class":220},[58,256,224],{"class":169},[58,258,227],{"class":220},[58,260,230],{"class":220},[58,262,233],{"class":169},[58,264,236],{"class":220},[58,266,267],{"class":169}," \"PAUSE;\"\n",[58,269,270,272,274,276,278,280,282,284],{"class":60,"line":92},[58,271,217],{"class":216},[58,273,221],{"class":220},[58,275,224],{"class":169},[58,277,227],{"class":220},[58,279,230],{"class":220},[58,281,233],{"class":169},[58,283,236],{"class":220},[58,285,286],{"class":169}," \"RESUME;\"\n",[10,288,289],{},"On Supabase, the pooler config picks up role changes automatically within a few minutes, but if you're not seeing the new credential take effect, restarting the pooler add-on from the dashboard forces it.",[291,292,293],"tip-box",{},[10,294,295],{},"If your rotation \"worked\" in staging but \"randomly\" fails intermittently in production under load, this is almost always the cause: some fraction of requests are still hitting pooled connections opened under the credential you just revoked.",[23,297,299],{"id":298},"step-4-confirm-nothing-still-uses-the-old-credential","Step 4: Confirm Nothing Still Uses the Old Credential",[10,301,302],{},"Before revoking anything, verify.",[48,304,306],{"className":50,"code":305,"language":52,"meta":53,"style":53},"-- Postgres: see active connections and which role they're using\nSELECT usename, count(*) FROM pg_stat_activity GROUP BY usename;\n\n-- MySQL: see active connections by user\nSHOW PROCESSLIST;\n",[55,307,308,313,318,322,327],{"__ignoreMap":53},[58,309,310],{"class":60,"line":61},[58,311,312],{},"-- Postgres: see active connections and which role they're using\n",[58,314,315],{"class":60,"line":67},[58,316,317],{},"SELECT usename, count(*) FROM pg_stat_activity GROUP BY usename;\n",[58,319,320],{"class":60,"line":73},[58,321,83],{"emptyLinePlaceholder":82},[58,323,324],{"class":60,"line":79},[58,325,326],{},"-- MySQL: see active connections by user\n",[58,328,329],{"class":60,"line":86},[58,330,331],{},"SHOW PROCESSLIST;\n",[10,333,334],{},"If the old credential's username still shows active connections after you believe every consumer has migrated, something was missed. Find it before proceeding.",[23,336,338],{"id":337},"step-5-revoke-the-old-credential","Step 5: Revoke the Old Credential",[48,340,342],{"className":50,"code":341,"language":52,"meta":53,"style":53},"-- Postgres: reassign any owned objects first, then drop the role\nREASSIGN OWNED BY app_user_old_role TO app_user_v2;\nDROP OWNED BY app_user_old_role;\nDROP ROLE app_user_old_role;\n\n-- MySQL: discard the retained old password\nALTER USER 'app_user'@'%' DISCARD OLD PASSWORD;\n",[55,343,344,349,354,359,364,368,373],{"__ignoreMap":53},[58,345,346],{"class":60,"line":61},[58,347,348],{},"-- Postgres: reassign any owned objects first, then drop the role\n",[58,350,351],{"class":60,"line":67},[58,352,353],{},"REASSIGN OWNED BY app_user_old_role TO app_user_v2;\n",[58,355,356],{"class":60,"line":73},[58,357,358],{},"DROP OWNED BY app_user_old_role;\n",[58,360,361],{"class":60,"line":79},[58,362,363],{},"DROP ROLE app_user_old_role;\n",[58,365,366],{"class":60,"line":86},[58,367,83],{"emptyLinePlaceholder":82},[58,369,370],{"class":60,"line":92},[58,371,372],{},"-- MySQL: discard the retained old password\n",[58,374,376],{"class":60,"line":375},7,[58,377,378],{},"ALTER USER 'app_user'@'%' DISCARD OLD PASSWORD;\n",[10,380,381],{},"Only run this after Step 4 comes back clean. This is the point of no return: anything still holding the old credential starts failing the moment it's revoked, the same failure mode you were trying to avoid in the first place.",[23,383,385],{"id":384},"when-to-rotate-outside-a-schedule","When to Rotate Outside a Schedule",[10,387,388],{},"Regular rotation on a schedule is good hygiene, but rotate immediately, not on your next maintenance window, when:",[390,391,392,400,403,406],"ul",{},[393,394,395,396,399],"li",{},"A ",[55,397,398],{},".env"," file or connection string was committed to git, even briefly.",[393,401,402],{},"A contractor or former employee had direct database access.",[393,404,405],{},"A backup or database dump was shared over email, Slack, or an unencrypted channel.",[393,407,408],{},"Your scanner or a security researcher reports the connection string appearing in a client-side JavaScript bundle.",[10,410,411],{},"Ransom demands for exposed, unrotated databases typically run $500 to $50,000, and attackers scan for exposed databases within hours of them going live. The dual-credential rotation above takes maybe 20 minutes across a small stack; that's the actual cost comparison.",[413,414,415,422,428,434,440],"faq-section",{},[416,417,419],"faq-item",{"question":418},"Why does rotating a database password break my app?",[10,420,421],{},"Because most guides tell you to change the password in place, which invalidates every existing connection immediately, before you've updated the app servers, cron jobs, and background workers that still hold the old value. Anything not yet redeployed starts throwing authentication errors the instant you save the change.",[416,423,425],{"question":424},"What is the dual-credential rotation pattern?",[10,426,427],{},"Instead of changing a password in place, you create a second credential (a new database role, or a second password on the same role if your database supports it) that works alongside the old one. You migrate every consumer to the new credential one at a time, confirm nothing is still using the old one, then revoke it. Nothing goes down because both credentials are valid during the transition.",[416,429,431],{"question":430},"Why does my app still fail auth after I updated the connection string everywhere?",[10,432,433],{},"Connection poolers like PgBouncer or Supabase's pooler cache already-authenticated connections and reuse them. Updating your app's environment variable doesn't close those cached connections. You need to reload or restart the pooler itself, not just redeploy the app, before the new credential takes effect everywhere.",[416,435,437],{"question":436},"Does Postgres support two active passwords on one role at the same time?",[10,438,439],{},"No, a single Postgres role has one password. For true zero-downtime rotation on Postgres, create a second role with equivalent grants, migrate consumers to it, then drop the original role. MySQL 8 and later supports dual passwords natively via ALTER USER ... RETAIN CURRENT PASSWORD, which is simpler.",[416,441,443],{"question":442},"How do I know when it's safe to revoke the old credential?",[10,444,445],{},"Check your database's active connection list (pg_stat_activity on Postgres, SHOW PROCESSLIST on MySQL) filtered by username, or check your database provider's audit log for auth events under the old credential. If nothing has authenticated with it in the last deployment cycle across all your services, it's safe to revoke.",[447,448,449,455,460],"related-articles",{},[450,451],"related-card",{"description":452,"href":453,"title":454},"The same emergency-rotation instinct, applied to Stripe, OpenAI, Supabase, and other third-party API keys.","/blog/how-to/rotate-api-keys","How to Rotate API Keys",[450,456],{"description":457,"href":458,"title":459},"Why poolers cache connections in the first place, and how to configure them without opening a security gap.","/blog/how-to/connection-pooling","How to Configure Connection Pooling Securely",[450,461],{"description":462,"href":463,"title":464},"The full pre-launch checklist: RLS, encryption, backups, and credential hygiene in one place.","/blog/checklists/database-security-checklist","Database Security Checklist",[466,467,470],"cta-box",{"href":468,"label":469},"/","Scan Your Database Config",[10,471,472],{},"Check whether your database credentials, connection strings, or pooler config are exposed anywhere they shouldn't be. Free scan, no signup required.",[474,475,476],"style",{},"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);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":53,"searchDepth":67,"depth":67,"links":478},[479,480,481,482,483,484,485],{"id":25,"depth":67,"text":26},{"id":35,"depth":67,"text":36},{"id":139,"depth":67,"text":140},{"id":191,"depth":67,"text":192},{"id":298,"depth":67,"text":299},{"id":337,"depth":67,"text":338},{"id":384,"depth":67,"text":385},"how-to","2026-07-06","Changing a database password the naive way takes your app offline. The dual-credential pattern doesn't: create a new role, migrate every consumer, then revoke the old one.",false,"md",[492,493,494,495,496],{"question":418,"answer":421},{"question":424,"answer":427},{"question":430,"answer":433},{"question":436,"answer":439},{"question":442,"answer":445},"yellow",null,"rotate database credentials, change database password without downtime, database credential rotation, postgres password rotation, mongodb atlas rotate password, connection pooler cache",{},"Rotate a Postgres, MySQL, MongoDB, or Redis credential without downtime using the dual-credential pattern, and avoid the connection-pooler cache gotcha that breaks most rotation attempts.","/blog/how-to/rotate-database-credentials","8 min read","[object Object]","HowTo",{"title":5,"description":488},{"loc":502},"blog/how-to/rotate-database-credentials",[],"summary_large_image","nmFXQEcD-cI5jMxxKPUidv8X9x_RF_du6j_wbp3mqA4",1784736378961]