[GH-ISSUE #2181] OAuth tokens are not encrypted in DB #9088

Closed
opened 2026-04-13 04:23:44 -05:00 by GiteaMirror · 15 comments
Owner

Originally created by @danielkrajka on GitHub (Apr 8, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/2181

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Setup Better Auth in Next.js
  2. Add GitHub OAuth provider
  3. Sign in using GitHub
  4. Check the accessToken and refreshToken columns in the Account table in DB

Current vs. Expected behavior

Access and refresh tokens should be encrypted by default when stored in the DB.

What version of Better Auth are you using?

1.2.5

Which area(s) are affected? (Select all that apply)

Backend

Additional context

Access and refresh tokens grant access to 3rd party systems and should be treated with more security. If a potential attacker gains access to the DB - they get free access tokens to specific providers.

Originally created by @danielkrajka on GitHub (Apr 8, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/2181 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Setup Better Auth in Next.js 2. Add GitHub OAuth provider 3. Sign in using GitHub 4. Check the accessToken and refreshToken columns in the Account table in DB ### Current vs. Expected behavior Access and refresh tokens should be encrypted by default when stored in the DB. ### What version of Better Auth are you using? 1.2.5 ### Which area(s) are affected? (Select all that apply) Backend ### Additional context Access and refresh tokens grant access to 3rd party systems and should be treated with more security. If a potential attacker gains access to the DB - they get free access tokens to specific providers.
GiteaMirror added the locked label 2026-04-13 04:23:44 -05:00
Author
Owner

@Kinfe123 commented on GitHub (Apr 9, 2025):

Access tokens are short-lived (like minutes or hours), so encrypting them is usually overkill— may be signing them up well But refresh tokens? Those things last way longer (think days or months), so they need extra protection. Store them super securely—like hashed on your server or locked down in HTTP-only cookies. happy to discuss more! elighten me your thoughts on this.

<!-- gh-comment-id:2790920334 --> @Kinfe123 commented on GitHub (Apr 9, 2025): Access tokens are short-lived (like minutes or hours), so encrypting them is usually overkill— may be signing them up well But refresh tokens? Those things last way longer (think days or months), so they need extra protection. Store them super securely—like hashed on your server or locked down in HTTP-only cookies. happy to discuss more! elighten me your thoughts on this.
Author
Owner

@pvcnt commented on GitHub (Apr 11, 2025):

If we are able to encrypt refresh tokens, I suppose it's not much more work to encrypt access tokens as well. I'd love this to happen as well. I was about to use Better Auth with GitHub login, but having non encrypted tokens is a no-go for me right now.

<!-- gh-comment-id:2796381416 --> @pvcnt commented on GitHub (Apr 11, 2025): If we are able to encrypt refresh tokens, I suppose it's not much more work to encrypt access tokens as well. I'd love this to happen as well. I was about to use Better Auth with GitHub login, but having non encrypted tokens is a no-go for me right now.
Author
Owner

@danielkrajka commented on GitHub (Apr 11, 2025):

IMO I think both of them should be encrypted, couple of hours is still a long time and some of the refresh tokens never expire.

Currently you can do the encryption on your own using the database before hooks (https://www.better-auth.com/docs/concepts/database#1-before-hook)

databaseHooks: {
    account: {
      create: {
        before(account, context) {
          const resultAccount = { ...account };

          if (account.accessToken) {
              const encryptedAccessToken = encrypt(account.accessToken) 
              resultAccount.accessToken = encryptedAccessToken;
          }
          if (account.refreshToken) {
              const encryptedRefreshToken = encrypt(account.refreshToken);
              resultAccount.refreshToken = encryptedRefreshToken;
          }

          return Promise.resolve({
            data: resultAccount
          });
        },
      }
    }
  }

You'd have to remember to decrypt it before using it again of course.

But I still think Better-Auth should have this built-in - rest of the library is so plug and play already.
You could have the encryption on by default and if you really want to disable it, you can use some flag on the betterAuth() instance.

<!-- gh-comment-id:2796820478 --> @danielkrajka commented on GitHub (Apr 11, 2025): IMO I think both of them should be encrypted, couple of hours is still a long time and some of the refresh tokens never expire. Currently you can do the encryption on your own using the database before hooks (https://www.better-auth.com/docs/concepts/database#1-before-hook) ```js databaseHooks: { account: { create: { before(account, context) { const resultAccount = { ...account }; if (account.accessToken) { const encryptedAccessToken = encrypt(account.accessToken) resultAccount.accessToken = encryptedAccessToken; } if (account.refreshToken) { const encryptedRefreshToken = encrypt(account.refreshToken); resultAccount.refreshToken = encryptedRefreshToken; } return Promise.resolve({ data: resultAccount }); }, } } } ``` You'd have to remember to decrypt it before using it again of course. But I still think Better-Auth should have this built-in - rest of the library is so plug and play already. You could have the encryption on by default and if you really want to disable it, you can use some flag on the betterAuth() instance.
Author
Owner

@tehnrd commented on GitHub (Apr 15, 2025):

Both should be encrypted, and this is not even debatable.

At minimum, better-auth should encrypt with the BETTER_AUTH_SECRET but ideally, there is are encrypt/decrypt functions that can be defined, as I want to use AWS KMS to do the encryption.

<!-- gh-comment-id:2805186205 --> @tehnrd commented on GitHub (Apr 15, 2025): Both should be encrypted, and this is not even debatable. At minimum, better-auth should encrypt with the `BETTER_AUTH_SECRET` but ideally, there is are encrypt/decrypt functions that can be defined, as I want to use AWS KMS to do the encryption.
Author
Owner

@DePasqualeOrg commented on GitHub (Apr 23, 2025):

Things like this make me hesitant to use Better Auth. Is this project production-ready?

<!-- gh-comment-id:2823500052 --> @DePasqualeOrg commented on GitHub (Apr 23, 2025): Things like this make me hesitant to use Better Auth. Is this project production-ready?
Author
Owner

@Bekacru commented on GitHub (Apr 23, 2025):

We didn’t want to encrypt tokens by default because there’s no easy path for most users to decrypt and use them. When encryption is needed, we provide a simpler path using hooks, which allows allow you to fully own the encryption and decryption layer. That said, we’re considering adding built-in support for this in v1.3

<!-- gh-comment-id:2823648069 --> @Bekacru commented on GitHub (Apr 23, 2025): We didn’t want to encrypt tokens by default because there’s no easy path for most users to decrypt and use them. When encryption is needed, we provide a simpler path using hooks, which allows allow you to fully own the encryption and decryption layer. That said, we’re considering adding built-in support for this in v1.3
Author
Owner

@danielkrajka commented on GitHub (Apr 23, 2025):

Maybe a docs warning section should be added that let's people know that the tokens are unencrypted and you have to do it yourself?

<!-- gh-comment-id:2823691596 --> @danielkrajka commented on GitHub (Apr 23, 2025): Maybe a docs warning section should be added that let's people know that the tokens are unencrypted and you have to do it yourself?
Author
Owner

@Bekacru commented on GitHub (Apr 23, 2025):

@danielkrajka good suggestion. It's now up in the user-accounts docs

<!-- gh-comment-id:2823946865 --> @Bekacru commented on GitHub (Apr 23, 2025): @danielkrajka good suggestion. It's now up in the user-accounts docs
Author
Owner

@tehnrd commented on GitHub (Apr 24, 2025):

Perhaps also update the documentation with an example of how to use hooks for encrypting and decrypting values?

I'm happy to implement and own this myself, but it wasn't clear how to do this.

...ignore me, I see the updated documentation does have an example: https://www.better-auth.com/docs/concepts/users-accounts#token-encryption

I'm happy with this approach and that it hasn't been documented.

<!-- gh-comment-id:2828953227 --> @tehnrd commented on GitHub (Apr 24, 2025): ~~Perhaps also update the documentation with an example of how to use hooks for encrypting and decrypting values?~~ ~~I'm happy to implement and own this myself, but it wasn't clear how to do this.~~ ...ignore me, I see the updated documentation does have an example: https://www.better-auth.com/docs/concepts/users-accounts#token-encryption I'm happy with this approach and that it hasn't been documented.
Author
Owner

@tehnrd commented on GitHub (Apr 25, 2025):

Looks like these values get updated when the user logins in again. Here is what I created and I think it is working...

	databaseHooks: {
		account: {
			create: {
				async before(account) {
					const withEncryptedTokens = { ...account };

					if (account.accessToken) {
						withEncryptedTokens.accessToken = await encryptString(account.accessToken);
					}

					if (account.refreshToken) {
						withEncryptedTokens.refreshToken = await encryptString(account.refreshToken);
					}

					return {
						data: withEncryptedTokens
					};
				}
			},
			update: {
				async before(account) {
					// Query the user to get the current access and refresh tokens
					const existingAccount = await db
						.select()
						.from(schema.account)
						.where(eq(schema.account.id, account.id!))
						.limit(1)
						.then((results) => results[0] || null);

					const withEncryptedTokens = { ...account };

					// Only encrypt the tokens if they have changed
					if (account.accessToken && existingAccount?.accessToken !== account.accessToken) {
						withEncryptedTokens.accessToken = await encryptString(account.accessToken);
					}

					if (account.refreshToken && existingAccount?.refreshToken !== account.refreshToken) {
						withEncryptedTokens.refreshToken = await encryptString(account.refreshToken);
					}

					return {
						data: withEncryptedTokens
					};
				}
			}
		}
	}

(sorry for the tabs 😬)

<!-- gh-comment-id:2829150468 --> @tehnrd commented on GitHub (Apr 25, 2025): Looks like these values get updated when the user logins in again. Here is what I created and I think it is working... ``` databaseHooks: { account: { create: { async before(account) { const withEncryptedTokens = { ...account }; if (account.accessToken) { withEncryptedTokens.accessToken = await encryptString(account.accessToken); } if (account.refreshToken) { withEncryptedTokens.refreshToken = await encryptString(account.refreshToken); } return { data: withEncryptedTokens }; } }, update: { async before(account) { // Query the user to get the current access and refresh tokens const existingAccount = await db .select() .from(schema.account) .where(eq(schema.account.id, account.id!)) .limit(1) .then((results) => results[0] || null); const withEncryptedTokens = { ...account }; // Only encrypt the tokens if they have changed if (account.accessToken && existingAccount?.accessToken !== account.accessToken) { withEncryptedTokens.accessToken = await encryptString(account.accessToken); } if (account.refreshToken && existingAccount?.refreshToken !== account.refreshToken) { withEncryptedTokens.refreshToken = await encryptString(account.refreshToken); } return { data: withEncryptedTokens }; } } } } ``` (sorry for the tabs 😬)
Author
Owner

@noorvir commented on GitHub (Jul 18, 2025):

Using the database-hook strategy for encrypting tokens suggested in the docs causes the auth.api.getAccessToken call to fail. I'm guessing this is because there is no automatic decryption mechanism which causes the refresh token to be "malformed"

error {
  "status": "BAD_REQUEST",
  "body": {
    "code": "FAILED_TO_GET_A_VALID_ACCESS_TOKEN",
    "message": "Failed to get a valid access token",
    "cause": {
      "error": "invalid_grant",
      "error_description": "AADSTS9002313: Invalid request. Request is malformed or invalid. Trace ID: d2b9fff8-da7c-4a57-b80f-a43a3db32f00 Correlation ID: 72f2c0d7-e831-4c5e-bdf0-da09a83580a2 Timestamp: 2025-07-18 07:12:49Z",
      "error_codes": [
        9002313
      ],
      "timestamp": "2025-07-18 07:12:49Z",
      "trace_id": "d2b9fff8-da7c-4a57-b80f-a43a3db32f00",
      "correlation_id": "72f2c0d7-e831-4c5e-bdf0-da09a83580a2",
      "error_uri": "https://login.microsoftonline.com/error?code=9002313",
      "status": 400,
      "statusText": "Bad Request"
    }
  },
  "headers": {},
  "statusCode": 400,
  "name": "APIError"
}
<!-- gh-comment-id:3087646728 --> @noorvir commented on GitHub (Jul 18, 2025): Using the database-hook strategy for encrypting tokens suggested in the docs causes the `auth.api.getAccessToken` call to fail. I'm guessing this is because there is no automatic decryption mechanism which causes the refresh token to be "malformed" ```json error { "status": "BAD_REQUEST", "body": { "code": "FAILED_TO_GET_A_VALID_ACCESS_TOKEN", "message": "Failed to get a valid access token", "cause": { "error": "invalid_grant", "error_description": "AADSTS9002313: Invalid request. Request is malformed or invalid. Trace ID: d2b9fff8-da7c-4a57-b80f-a43a3db32f00 Correlation ID: 72f2c0d7-e831-4c5e-bdf0-da09a83580a2 Timestamp: 2025-07-18 07:12:49Z", "error_codes": [ 9002313 ], "timestamp": "2025-07-18 07:12:49Z", "trace_id": "d2b9fff8-da7c-4a57-b80f-a43a3db32f00", "correlation_id": "72f2c0d7-e831-4c5e-bdf0-da09a83580a2", "error_uri": "https://login.microsoftonline.com/error?code=9002313", "status": 400, "statusText": "Bad Request" } }, "headers": {}, "statusCode": 400, "name": "APIError" } ```
Author
Owner

@philgineered commented on GitHub (Jul 22, 2025):

I’m seeing the same behavior. When I manually replace the encrypted refresh token in the database with the decrypted version, everything works as expected — so the problem seems to be the lack of automatic decryption.

Using the DB hook strategy to encrypt tokens causes auth.api.getAccessToken to fail, likely because the refresh token is encrypted and ends up being considered "malformed" by the provider.

I see two possible approaches to address this:

Introduce another DB hook that modifies (e.g., decrypts) the account after it's read from the database, before it's passed on for further processing. Therefore we would need a new account:read:after hook.

Alternatively, extend the genericOAuth provider to allow a refreshAccessToken function to be defined, similar to how it works for social providers (see: refreshAccessToken). So, we can handle the refreshing of the token by ourselfs.

<!-- gh-comment-id:3102873084 --> @philgineered commented on GitHub (Jul 22, 2025): I’m seeing the same behavior. When I manually replace the encrypted refresh token in the database with the decrypted version, everything works as expected — so the problem seems to be the lack of automatic decryption. Using the DB hook strategy to encrypt tokens causes auth.api.getAccessToken to fail, likely because the refresh token is encrypted and ends up being considered "malformed" by the provider. I see two possible approaches to address this: Introduce another DB hook that modifies (e.g., decrypts) the account after it's read from the database, before it's passed on for further processing. Therefore we would need a new `account:read:after` hook. Alternatively, extend the genericOAuth provider to allow a refreshAccessToken function to be defined, similar to how it works for social providers (see: [refreshAccessToken](https://www.better-auth.com/docs/concepts/oauth)). So, we can handle the refreshing of the token by ourselfs.
Author
Owner

@rigelgfr commented on GitHub (Jul 28, 2025):

would be great if decryption is handled automatically in getAccessToken cause the auto refresh is really nice

<!-- gh-comment-id:3125611691 --> @rigelgfr commented on GitHub (Jul 28, 2025): would be great if decryption is handled automatically in getAccessToken cause the auto refresh is really nice
Author
Owner

@GrahamWilsdon commented on GitHub (Aug 8, 2025):

Using the DB hook strategy to encrypt tokens causes auth.api.getAccessToken to fail, likely because the refresh token is encrypted and ends up being considered "malformed" by the provider.

We can assume codebases using getAccessToken DO NOT encrypt their tokens in the DB?

<!-- gh-comment-id:3168345722 --> @GrahamWilsdon commented on GitHub (Aug 8, 2025): > Using the DB hook strategy to encrypt tokens causes auth.api.getAccessToken to fail, likely because the refresh token is encrypted and ends up being considered "malformed" by the provider. We can assume codebases using `getAccessToken` **DO NOT** encrypt their tokens in the DB?
Author
Owner

@ping-maxwell commented on GitHub (Aug 11, 2025):

Hello all, I'm going to close this issue as the original post of this issue is now resolved. If you have additional concerns please open a separate issue.
if you have a small/quick question please tag me here with it and I'll see if it makes sense to be an issue or if I can resolve it.

<!-- gh-comment-id:3176219786 --> @ping-maxwell commented on GitHub (Aug 11, 2025): Hello all, I'm going to close this issue as the original post of this issue is now resolved. If you have additional concerns please open a separate issue. if you have a small/quick question please tag me here with it and I'll see if it makes sense to be an issue or if I can resolve it.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9088