docs: clarify stateless Cognito token refresh (#10092)

This commit is contained in:
Gustavo Valverde
2026-06-15 17:06:35 -07:00
committed by GitHub
parent de4aa52e99
commit 1e69725027
6 changed files with 199 additions and 10 deletions

View File

@@ -76,6 +76,15 @@ description: Amazon Cognito provider setup and usage.
* `aws.cognito.signin.user.admin`: Grants access to Cognito-specific APIs
* Note: You must configure the scopes in your Cognito App Client settings. [available scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html#token-endpoint-userinfo)
* `getUserInfo`: Custom function to retrieve user information from the Cognito UserInfo endpoint.
* `refreshAccessToken`: Custom function to refresh tokens. It receives the stored refresh token.
### Refresh tokens
Cognito returns a refresh token after a successful authorization code grant. Later refresh-token grants return new access and ID tokens. Cognito only returns a new refresh token when refresh token rotation is enabled in the app client; otherwise, the original refresh token remains valid and Better Auth keeps using it.
`auth.api.getAccessToken` refreshes an expired access token automatically when the provider account has a refresh token and a known `accessTokenExpiresAt`. It returns the valid access token and ID token. If you need the refresh token in the response, use the `/refresh-token` endpoint instead.
In database-less setups, Better Auth stores provider account data, including OAuth token material, in the encrypted `account_data` cookie when `storeAccountCookie` is enabled. Token refresh responses set an updated cookie, so server-side callers must forward the returned `Set-Cookie` header to the browser. Cognito JWTs can be large; Better Auth chunks oversized account cookies, but browsers and proxies can still enforce total header limits. Use database-backed account storage for large token payloads or production flows that need durable token storage.
<Callout type="info">
For more information about Amazon Cognito's scopes and API capabilities, refer to the [official documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html?utm_source).

View File

@@ -391,7 +391,7 @@ export const auth = betterAuth({
},
account: {
storeStateStrategy: "cookie",
storeAccountCookie: true, // Store account data after OAuth flow in a cookie (useful for database-less flows)
storeAccountCookie: true, // Store provider account data after OAuth flow in an encrypted cookie
}
});
```
@@ -400,6 +400,10 @@ export const auth = betterAuth({
If you don't provide a database, by default we provide the above configuration for you.
</Callout>
In stateless OAuth flows, `storeAccountCookie` stores provider account data, including OAuth token material, in the encrypted `account_data` cookie. `getAccessToken` can refresh expired provider access tokens when the account cookie contains a refresh token and a known access-token expiry. Token refresh responses set an updated account cookie, so server-side integrations must forward the returned `Set-Cookie` header to the browser.
Better Auth chunks oversized account cookies, but browsers and proxies can still enforce total cookie or header limits. Use database-backed account storage for providers that issue large JWTs or for production flows that need durable token storage.
### Understanding `refreshCache`
The `refreshCache` option controls automatic cookie refresh **before expiry** without querying any database:

View File

@@ -350,7 +350,7 @@ export const auth = betterAuth({
* `prompt`: The prompt to use for the authorization code request (`"select_account"`, `"consent"`, `"login"`, `"none"`, `"select_account consent"`) (optional)
* `responseMode`: The response mode to use (`"query"`, `"form_post"`) (optional)
* `getUserInfo`: Custom function to get user info from the provider (optional)
* `refreshAccessToken`: Custom function to refresh a token (optional)
* `refreshAccessToken`: Custom function to refresh a token. Receives the stored refresh token and returns new OAuth tokens. (optional)
* `verifyIdToken`: Custom function to verify the ID token (optional)
* `disableIdTokenSignIn`: Disable sign in with ID token sent from the client (optional)
* `disableDefaultScope`: Disable the provider's default scopes (optional)
@@ -475,7 +475,7 @@ export const auth = betterAuth({
},
encryptOAuthTokens: true, // Encrypt OAuth tokens before storing them in the database
storeStateStrategy: "database", // Store OAuth state payload in verification storage
storeAccountCookie: true, // Store account data after OAuth flow in a cookie (useful for database-less flows)
storeAccountCookie: true, // Store provider account data after OAuth flow in an encrypted cookie (useful for database-less flows)
accountLinking: {
enabled: true,
trustedProviders: ["google", "github", "email-password"], // or async (request) => ["google", "github"]
@@ -508,11 +508,15 @@ Defaults to `"database"` when a database or `secondaryStorage` is configured, an
### `storeAccountCookie`
Store account data after OAuth flow in a cookie. This is useful for database-less flows where you want to store account information (access tokens, refresh tokens, etc.) in a cookie instead of the database.
Store provider account data after an OAuth flow in an encrypted cookie. This is useful for database-less flows where OAuth token material, such as access tokens, refresh tokens, ID tokens, scopes, and token expiry, must be available without an account table.
* Default: `false`
* Automatically set to `true` if no database is provided
The account cookie has a five-minute max age and is refreshed when Better Auth writes updated provider account data, for example after `getAccessToken` or `/refresh-token` refreshes provider tokens. When this happens in server-side code, forward the returned `Set-Cookie` header to the browser so the refreshed account cookie is retained.
Better Auth chunks oversized account cookies, but browsers and proxies can still enforce total cookie or header limits. Prefer database-backed account storage for providers that issue large JWTs or for production flows that need durable token storage.
When this option is enabled, you can read the decrypted account cookie in hooks or middleware using `getAccountCookie` from `better-auth/cookies`:
```ts

View File

@@ -1,5 +1,8 @@
import { BASE_ERROR_CODES } from "@better-auth/core/error";
import type { GoogleProfile } from "@better-auth/core/social-providers";
import type {
CognitoProfile,
GoogleProfile,
} from "@better-auth/core/social-providers";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import type { MockInstance } from "vitest";
@@ -1293,7 +1296,7 @@ describe("account", async () => {
it("should persist refreshed idToken in account cookie during getAccessToken auto-refresh in stateless mode", async () => {
const { auth, client, cookieSetter } = await getTestInstance({
database: undefined as any,
database: undefined,
socialProviders: {
google: {
clientId: "test",
@@ -1452,6 +1455,172 @@ describe("account", async () => {
expect(refreshTokenCalls).toBeGreaterThan(0);
});
/**
* @see https://github.com/better-auth/better-auth/issues/8562
*/
it("should preserve the Cognito refresh token when getAccessToken auto-refresh receives no replacement", async () => {
const cognitoDomain = "test.auth.us-east-1.amazoncognito.com";
const cognitoIssuer =
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_testpool";
const signCognitoIdToken = (jti: string) => {
const now = Math.floor(Date.now() / 1000);
return signJWT(
{
email: "cognito-refresh@test.com",
email_verified: true,
name: "Cognito User",
exp: now + 3600,
sub: "cognito-user-sub",
iat: now,
aud: "cognito-client",
iss: cognitoIssuer,
jti,
} satisfies CognitoProfile,
DEFAULT_SECRET,
);
};
const { auth, client, cookieSetter } = await getTestInstance({
database: undefined,
socialProviders: {
cognito: {
clientId: "cognito-client",
clientSecret: "cognito-secret",
domain: cognitoDomain,
region: "us-east-1",
userPoolId: "us-east-1_testpool",
},
},
account: {
storeAccountCookie: true,
},
});
const authContext = await auth.$context;
const accountDataCookieName = authContext.authCookies.accountData.name;
const refreshGrantRefreshTokens: string[] = [];
const initialIdToken = await signCognitoIdToken("initial-cognito-id-token");
const refreshedIdToken = await signCognitoIdToken(
"refreshed-cognito-id-token",
);
server.use(
http.post(
`https://${cognitoDomain}/oauth2/token`,
async ({ request }) => {
const params = new URLSearchParams(await request.text());
const grantType = params.get("grant_type");
if (grantType === "refresh_token") {
const refreshToken = params.get("refresh_token");
refreshGrantRefreshTokens.push(refreshToken || "");
return HttpResponse.json({
access_token: "refreshed-cognito-access-token",
expires_in: 3600,
id_token: refreshedIdToken,
token_type: "Bearer",
});
}
return HttpResponse.json({
access_token: "initial-cognito-access-token",
expires_in: 1,
id_token: initialIdToken,
refresh_token: "cognito-refresh-token",
token_type: "Bearer",
});
},
),
);
const headers = new Headers();
const signInRes = await client.signIn.social({
provider: "cognito",
callbackURL: "/callback",
fetchOptions: {
onSuccess: cookieSetter(headers),
},
});
expect(signInRes.data).toMatchObject({
url: expect.stringContaining(cognitoDomain),
redirect: true,
});
const state =
signInRes.data && "url" in signInRes.data && signInRes.data.url
? new URL(signInRes.data.url).searchParams.get("state") || ""
: "";
await client.$fetch("/callback/cognito", {
query: {
state,
code: "test",
},
headers,
method: "GET",
onError(context) {
expect(context.response.status).toBe(302);
cookieSetter(headers)({ response: context.response });
},
});
let refreshedAccountCookie: string | undefined;
const accessTokenResponse = await client.getAccessToken(
{
providerId: "cognito",
},
{
headers,
onSuccess(context) {
cookieSetter(headers)(context);
const cookies = parseSetCookieHeader(
context.response.headers.get("set-cookie") || "",
);
refreshedAccountCookie =
cookies.get(accountDataCookieName)?.value || undefined;
},
},
);
expect(accessTokenResponse.error).toBeFalsy();
expect(accessTokenResponse.data?.accessToken).toBe(
"refreshed-cognito-access-token",
);
expect(accessTokenResponse.data?.idToken).toBe(refreshedIdToken);
expect(refreshGrantRefreshTokens).toEqual(["cognito-refresh-token"]);
expect(refreshedAccountCookie).toBeDefined();
await expect(
symmetricDecodeJWT(
refreshedAccountCookie!,
authContext.secret,
"better-auth-account",
),
).resolves.toMatchObject({
accessToken: "refreshed-cognito-access-token",
idToken: refreshedIdToken,
refreshToken: "cognito-refresh-token",
});
const refreshTokenResponse = await client.$fetch<{ refreshToken?: string }>(
"/refresh-token",
{
body: {
providerId: "cognito",
},
headers,
method: "POST",
},
);
expect(refreshTokenResponse.error).toBeFalsy();
expect(refreshTokenResponse.data?.refreshToken).toBe(
"cognito-refresh-token",
);
expect(refreshGrantRefreshTokens).toEqual([
"cognito-refresh-token",
"cognito-refresh-token",
]);
});
it("should NOT chunk account data cookies when exceeding 4KB", async () => {
const { client, cookieSetter } = await getTestInstance({
secret: "better-auth.secret",
@@ -1903,7 +2072,7 @@ describe("account", async () => {
const refreshUpdateAge = 60;
const { auth, client, cookieSetter } = await getTestInstance({
database: undefined as any,
database: undefined,
socialProviders: {
google: { clientId: "test", clientSecret: "test", enabled: true },
},

View File

@@ -454,7 +454,6 @@ describe("Social Providers", async (c) => {
const signInRes = await client.signIn.social({
provider: "google",
callbackURL: "/callback",
newUserCallbackURL: "/welcome",
fetchOptions: {
onSuccess: cookieSetter(headers),
},

View File

@@ -1160,9 +1160,13 @@ export type BetterAuthOptions = {
*/
storeStateStrategy?: "database" | "cookie";
/**
* Store account data after oauth flow on a cookie
* Store provider account data after an OAuth flow in an encrypted
* cookie. This includes OAuth token material such as access tokens,
* refresh tokens, ID tokens, scopes, and token expiry.
*
* This is useful for database-less flow
* This is useful for database-less flows, but large provider tokens can
* still hit browser or proxy cookie/header limits even though Better Auth
* chunks oversized account cookies.
*
* @default false
*