Better Auth 1.7.7: OAuth State vs Magic-Link Tokens

Guilliano Molaire Guilliano Molaire 11 min read

Better Auth 1.7.7, released on 30 September 2026, changes how the library stores verification records so that one feature can no longer redeem a record created by another. Before the change, a database-backed OAuth state value and a magic-link token were stored in the same verification store under the bare token value, and the Magic Link endpoint did not check which feature had created a record. Because the OAuth state record could carry client-supplied data, including an email field, our reading of the code is that a state value could be redeemed as if it were a magic link. If you use the Magic Link plugin alongside social sign-in, upgrade to 1.7.7 or later and ask users to request new magic links.

The general lesson applies to any login system, whether you embed an auth library in your app, run your own identity provider, or buy identity management as a service: every token or opaque value should be bound to one purpose, and the code that redeems it should refuse anything minted for a different purpose. This post walks through what the Better Auth fix changed, why the bug class is easy to ship, how Keycloak separates the same concerns, and a short checklist you can apply to your own auth code.

Key takeaways

  • Better Auth 1.7.7 (30 September 2026) adds magic-link: and auth-state: prefixes to verification records and derives separate encryption keys per OAuth purpose (Better Auth commit ac54bfd, 2026).
  • Magic links issued before the upgrade stop working, and OAuth sign-ins in flight must restart.
  • The underlying mistake is reusing one opaque-value namespace for two security purposes. Keycloak’s design keeps those purposes apart with typed, signed action tokens and client-bound authorization codes.

In 2026, Better Auth’s fix commit, “fix(auth): isolate verification records and encryption purposes” (pull request #11494, merged 30 September and released in 1.7.7 the same day), describes the problem through its changes. Before the fix, Better Auth wrote database-backed OAuth state under the raw state string as its identifier, and the Magic Link plugin looked up its tokens in the same verification store, also by the raw value. Both features used one namespace, and the Magic Link endpoint took whatever record matched.

Two details make that overlap security-relevant, by our reading of the code. First, Better Auth’s social sign-in accepts an additionalData object from the client and spreads it into the state record, so the caller can put arbitrary fields into what gets stored. Second, the Magic Link verify handler parses the stored record as JSON and reads email from it to decide which user to sign in. The new tests in the fix commit reproduce the combination: a social sign-in started with an email in additionalData, followed by an attempt to redeem the resulting state at the Magic Link endpoint, which the fixed version now rejects with INVALID_TOKEN.

We are not going to turn that into a step-by-step exploit. What matters for most teams is the shape of the mistake, which is easy to make in any codebase: two features that each needed “a random value we can look up later” shared a table and a lookup path, and only one of them expected the record to contain a trusted email address.

As of 3 October 2026 we found no CVE or GitHub security advisory for this change, and the maintainers describe it as isolating verification records rather than as a vulnerability. This post is therefore based on the fix commit, its tests and the release changesets, and the impact described below is our own reading of the code.

Which Better Auth setups were exposed?

By our reading of the 1.7.6 source, a setup needed three things for the two record types to collide. It had to use the Magic Link plugin, it had to store OAuth state in the database or in secondary storage (the database state strategy, which is the default whenever a database or secondary storage backs sessions), and it had to store magic-link tokens without a plugin-level hash (storeToken: "plain", the default). A setup that keeps OAuth state in an encrypted cookie never writes a state record, and with storeToken: "hashed" on the Magic Link plugin the lookup key is a hash of the token while state is stored raw, so the values do not line up. A global verification.storeIdentifier hash applies to both record types equally and does not separate them.

Where those conditions held, the likely effect was serious: someone could start a social sign-in with an email address of their choosing in additionalData and then present the resulting state to the Magic Link endpoint, which would treat it as proof of that email address. We have not tested this against a running instance, so treat it as an assessment rather than a confirmed exploit, and upgrade either way.

What changed in Better Auth 1.7.7?

The 1.7.7 changesets describe two separate fixes. In 2026, the “verification-purpose” changeset says that “Magic Link verification now accepts only records issued for Magic Link” and that Magic Link records and database-backed OAuth or SAML state “use separate verification identifier prefixes”, which are magic-link: and auth-state:. The second changeset, for the OAuth Proxy plugin, says OAuth state cookies and each OAuth Proxy payload are now encrypted with “purpose-specific encryption keys”, derived from your secret with HKDF so that a ciphertext made for one purpose cannot be decrypted as another.

Both changes are breaking for anything in flight, and the release notes say so plainly:

  • Magic links issued before the upgrade fail with INVALID_TOKEN. Users have to request a new one.
  • OAuth, account-linking and cookie-backed SAML sign-ins started before the upgrade must restart, because old state and proxy payloads cannot be decrypted with the new purpose keys.
  • All servers that share verification storage must upgrade together, including preview and development deployments that take part in an OAuth Proxy flow. Mixed old and new nodes cannot exchange state, and there is no fallback to the old shared key.
  • Custom identifier rules need updating. If you set verification.storeIdentifier.overrides, add rules for the new magic-link: and auth-state: prefixes.

If you are on the 1.6 line, the 1.6.33 source (tagged in mid-September 2026) stores OAuth state and magic-link tokens in the same un-prefixed way, and as of 3 October 2026 we found no 1.6.x tag carrying this fix. Unless the maintainers say otherwise, the safe assumption is that moving to 1.7.7 or later is how you get it.

What is the OAuth state parameter for?

The OAuth state parameter is a value the client generates when it starts a sign-in, sends to the authorization server, and expects to get back unchanged on the callback. RFC 6749, the OAuth 2.0 framework published in 2012, recommends it to protect the redirect endpoint against cross-site request forgery: the client only accepts a callback whose state matches one it issued. Many libraries, Better Auth included, also use the server-side record behind state to remember the PKCE verifier, the callback URL and similar details.

What state is not is a credential. Anyone can start a sign-in and read the state from the authorization URL, so it proves nothing about who the user is, and any data stored alongside it that came from the request has to be treated as untrusted. That is the property the Better Auth change restores, and it is why state should never share a lookup path with something that does prove identity.

Why do purpose-bound tokens matter?

A purpose-bound token is one that records what it is for, and whose verifier refuses it for anything else. RFC 8725, the IETF’s JSON Web Token Best Current Practices published in 2020, makes the same point for JWTs in its section on cross-JWT confusion: when one issuer mints tokens for different purposes, it recommends explicit typing (the typ header) and distinct audiences or claims so that a token for one context cannot be substituted in another. The Better Auth fix applies that idea to opaque database records and to encryption keys.

Opaque values make this easy to forget, because a random string does not look like it has a type, even though an OAuth state is a correlation value that anyone can trigger and observe while a magic-link token is a credential that proves control of an email inbox. Those are very different levels of trust, and storing them where one lookup can return either removes the difference between them. The same reasoning applies to password reset tokens, email verification links, invitation tokens and API keys, which is why our JWT best practices guide recommends checking typ, aud and issuer on every token you accept rather than only the signature. You can inspect those claims on any token with our JWT token analyzer.

How does Keycloak keep login tokens separate?

Keycloak handles the same jobs with values that carry their purpose with them, and with verifiers that are specific to one purpose. That does not make it immune to bugs, and Keycloak has its own CVE history like any widely used server, but four mechanisms in its design keep the purposes covered here apart.

Action tokens are typed, signed JWTs. Password reset links, email verification links, “execute actions” emails and organization invitations are all action tokens. In the Keycloak 26.8.0 source, each one carries its action type (for example reset-credentials) and is signed with the realm’s keys. The action token endpoint verifies the signature, the expiry and the realm issuer, then routes the token to the one handler registered for that type, so a verify-email token cannot be processed as a password reset. The reset-credentials handler also marks its tokens as single-use.

OAuth state stays the client’s value. Keycloak echoes state back to the client but never treats it as a credential, so there is nothing to redeem. The value that does carry authority, the authorization code, is bound to the client that requested it and to the redirect URI it was issued for, and with PKCE it is also bound to the client’s code verifier. Our PKCE client setup guide shows the configuration.

Brokered identity data is not trusted by default. When a user signs in through an external identity provider and an account with the same email already exists, Keycloak’s default first broker login flow asks the user to confirm the link by email verification or by re-authenticating, rather than signing them in on the strength of the email claim. An identity provider’s email is only treated as verified if an administrator turns on its “Trust Email” option.

There is no shared verification table to collide in. Keycloak 26.8 does not ship a magic-link authenticator. Passwordless login in Keycloak is usually done with passkeys: the passkeys feature is enabled by default in 26.8, and you turn on passkey login in the realm’s authentication flow, as our Keycloak passkeys guide shows. Teams that do want email links build them as a custom authenticator, and the action-token pattern above is the model to copy.

What a dedicated identity provider changes is where this code lives: the token handling is shared, reviewed and patched upstream, rather than being part of every application that embeds a library.

Better Auth vs Keycloak: which fits your app?

The two tools solve different problems. Better Auth is a TypeScript library you embed in your application: you own the database tables, the routes and the upgrade timing, and it is a good fit when one app needs sign-in and you want the auth code next to the rest of your code. Keycloak is a separate identity server that many applications share, and it brings standards-based single sign-on, SAML and OIDC federation for enterprise customers, admin consoles, and the token separation described above.

The trade-off shows up in incidents like this one. With an embedded library, the fix is a dependency bump, but every service that shares verification storage has to deploy it together, and in-flight logins break. With a central identity provider, there is one server to patch, though you take on running that server or paying someone to run it. If you are comparing options more broadly, our guide to open-source and managed Auth0 alternatives and our Clerk alternatives roundup cover the wider field, and is self-hosting Keycloak worth it covers the cost of running it yourself.

A checklist for your own auth code

Whatever you use, these checks catch this class of bug before it ships.

  1. Prefix or type every stored token. Give each purpose its own namespace (magic-link:, reset:, oauth-state:) or its own table, and have each verifier check it.
  2. Never derive identity from data the client supplied. If a record can contain client-controlled fields, the code that redeems it must not read the user’s identity from those fields.
  3. Separate encryption keys by purpose. Derive per-purpose keys from your main secret (HKDF is the usual tool) so ciphertext from one feature cannot be decrypted by another.
  4. Treat state as a correlation value only. Store it server-side or in an encrypted cookie, compare it on callback, and never read identity or authorization decisions from data that arrived with the request that created it.
  5. Make credentials single-use and short-lived. Consume magic-link and reset tokens atomically, and expire them in minutes.
  6. Log redemption failures. A spike in INVALID_TOKEN errors after an upgrade is expected; a steady trickle of mismatched-purpose redemptions is worth an alert.

If your team builds API access next to user sign-in, our comparison of API keys and OAuth covers how to keep those credentials apart as well.

FAQ

Is Better Auth safe to use?

Better Auth is a widely used library, and like any auth library it is as safe as the version you run and how you configure it. Version 1.7.7 separates OAuth state from magic-link records. If you use the Magic Link plugin alongside social sign-in, upgrade to 1.7.7 or later, upgrade every server that shares verification storage at the same time, and re-issue magic links.

Better Auth 1.7.7, released on 30 September 2026, contains the fix from pull request #11494. It prefixes verification records with magic-link: and auth-state: and uses purpose-specific encryption keys for OAuth state cookies and OAuth Proxy payloads. As of 3 October 2026 we found no 1.6.x release carrying the same change.

Not out of the box. Keycloak 26.8 ships no magic-link authenticator, so teams either use passkeys, whose feature is enabled by default in 26.8, or build a custom authenticator. If you build one, model it on Keycloak’s action tokens: signed, typed, short-lived and single-use. Our overview of passwordless authentication compares the options.

What is a purpose-bound token?

It is a token that records which feature issued it and is accepted only by that feature’s verifier. RFC 8725 (2020) recommends explicit typing to prevent cross-JWT confusion, and the same idea applies to opaque tokens through prefixes or separate tables. It stops a value made for one job, such as OAuth state, from being redeemed for another, such as a login link.

Do I need to migrate data when upgrading to Better Auth 1.7.7?

Existing users and accounts need no data migration, according to the Magic Link documentation updated in the fix, but pending records do need handling. Magic links issued before the upgrade fail with INVALID_TOKEN, and OAuth sign-ins in progress must restart. If you configured verification.storeIdentifier.overrides, update the rules to match the new magic-link: and auth-state: prefixes.

Sources

  • Better Auth, commit ac54bfd, “fix(auth): isolate verification records and encryption purposes (#11494)”, 30 September 2026, retrieved 2026-10-03, https://github.com/better-auth/better-auth/commit/ac54bfd67821a7607533af2059858b12bc54e26a
  • Better Auth, release v1.7.7, retrieved 2026-10-03, https://github.com/better-auth/better-auth/releases/tag/v1.7.7
  • Better Auth, oauth2/state.ts at v1.7.6 (additionalData spread into state data), retrieved 2026-10-03, https://github.com/better-auth/better-auth/blob/v1.7.6/packages/better-auth/src/oauth2/state.ts
  • Better Auth, Magic Link plugin documentation (source at the fix commit), retrieved 2026-10-03, https://github.com/better-auth/better-auth/blob/ac54bfd67821a7607533af2059858b12bc54e26a/docs/content/docs/plugins/magic-link.mdx
  • Keycloak, LoginActionsService.java and action token handlers at tag 26.8.0, retrieved 2026-10-03, https://github.com/keycloak/keycloak/tree/26.8.0/services/src/main/java/org/keycloak/authentication/actiontoken
  • Keycloak, Profile.java at tag 26.8.0 (passkeys enabled by default), retrieved 2026-10-03, https://github.com/keycloak/keycloak/blob/26.8.0/common/src/main/java/org/keycloak/common/Profile.java
  • IETF, RFC 8725, “JSON Web Token Best Current Practices”, 2020, https://datatracker.ietf.org/doc/html/rfc8725
  • IETF, RFC 7636, “Proof Key for Code Exchange by OAuth Public Clients”, 2015, https://datatracker.ietf.org/doc/html/rfc7636

Patched on the day, not on your next maintenance window

Keycloak 26.7.2 fixed CVE-2026-18963, an account takeover through password reset. Skycloak had it available for auto-upgrade and told customers the same day upstream shipped it. Self-hosted teams schedule that work themselves.

Guilliano Molaire
Written by
Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Start Free Trial Talk to Sales
© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman