Magic link logs into the wrong account: how to fix it
A magic link that opens another person’s account is a session bug, so revoke all active sessions now, then stop shared caches from storing login responses and find the code that picks the user. Treat the bug as a data exposure. The person who got the wrong session could read and change the data of another person.
This guide shows the five usual causes, how to test for each one, and how to fix them. It also shows how to check the damage and prevent the bug.
What does “magic link logs into the wrong account” mean?
It means that a person asks for a sign-in link, clicks it, and the app shows the account of someone else. The email address on the screen, the name, or the data does not match the person. The bug may happen one time in a hundred, which makes it hard to find.
A magic link has two parts. The first part is the token in the email. The token proves that the person controls the mailbox. The second part is the session, which is a cookie that the browser sends on each request. The app creates the session after it checks the token.
The wrong account can appear in either part. The token can map to the wrong user. The session can be mixed up after the token is valid. Most real cases come from the second part.
The Supabase docs for passwordless email state that magic links are one-time use and that they expire after 1 hour. The Auth.js email docs give 24 hours as the time to click the link. A correct provider does not give one token to two people. When you see the wrong account, the bug is usually in your own code or in the layer between the code and the browser.
The five common causes are:
| Cause | What happens |
|---|---|
| A shared cache stores a login response | The next visitor gets the session of the first visitor |
| The browser already has a session | The old session stays after the link opens |
| The server keeps the user in shared memory | One request reads the user of another request |
| The app finds the user by a loose email match | Two spellings of one address point to two accounts, or a wrong row |
| The mailbox or the link is shared | A second person opens the link |
How do I confirm which cause applies?
Use two browser profiles, or one normal window and one private window. Do not test with your own real account on production if the bug may expose data. Use two test accounts that you own.
- Test the existing session. Sign in as test user A in window 1. In window 1, request a magic link for test user B and open it. Read the name on the screen.
- Test with no session. Open a private window. Request a link for test user B and open it. Read the name on the screen.
- Test the cache headers. Run
curlon the callback and on the route that returns the current user.
curl -sI https://your-app.example/auth/callback | grep -iE "cache-control|set-cookie|age|vary"
curl -sI https://your-app.example/api/me | grep -iE "cache-control|age|vary"
- Read the result. The table shows what each header means.
| Header | Safe value | Risk |
|---|---|---|
Cache-Control |
private or no-store |
Missing, or public, or a long s-maxage |
Age |
Absent | A number above 0 means a cache served the response |
Set-Cookie |
Present only on the login response | Present on a cached response |
The MDN page for Cache-Control states the rule. If you forget private on personalized content, the response can be stored in a shared cache and reused for many users. This can cause personal information to leak.
- Search the code for shared state. Look for a variable at the top of a server file that holds a user, such as
let currentUser. Look for a global cache that keys on the path and not on the session.
Warning: If the first test shows a different user from the one you signed in as, assume that real users are affected. Revoke the sessions before you debug further.
How do I stop the exposure now?
Do these steps in order. Steps 1 and 2 stop the harm. The other steps remove the cause.
- Revoke all active sessions. A session that was created during the bug can belong to the wrong person. Use your provider to sign out all users, or delete the rows in your own sessions table. Users must sign in again.
- Disable the magic link sign-in for a short time, if you can. Show a message and keep a password or OAuth sign-in if you have one. This stops new wrong sessions while you fix the cause.
- Add
Cache-Control: private, no-storeto every login and session response. This covers the callback, the route that sets the cookie, and the route that returns the current user. - Replace the session in the callback. The callback must create a new session for the user in the token. It must not keep the old cookie.
- Remove shared user state from the server code. Read the user from the request in each handler.
- Match the user by the ID from the verified token. Do not match on an email address from the query string.
- Deploy, and run the tests again. Use the two-window test from the confirm section.
A route handler in Next.js can set the header like this:
export async function GET(req: Request) {
const user = await getUserFromRequest(req)
return Response.json(
{ id: user?.id ?? null },
{ headers: { "Cache-Control": "private, no-store" } },
)
}
Make sure that the CDN or the host does not override the header. If your host has a cache rule for /api/* or for /auth/*, remove the rule for these paths. Vary the cache key on the cookie only if you cannot avoid caching, and avoid caching in all other cases.
Why does the link open the account that was already signed in?
The browser sends the old cookie when it opens the link. If the callback does not replace the cookie, the old session stays. The person then sees the old account, even though the link was for a new one.
This often happens on a shared computer, on a phone where two people use one browser, and during your own tests. The Supabase docs for sessions state that, by default, a session lasts indefinitely and that a user can have an unlimited number of active sessions on many devices. A session does not end on its own when another person signs in on the same browser.
The fix is in the callback. Follow this rule: after the token is valid, always write a new session cookie, and delete the old one. Do not skip the write because a cookie exists.
If you want the app to protect the person, add a check. When the callback sees an existing session for a different user, show a message: “You are signed in as A. This link is for B. Sign out and continue?” This prevents a silent mix-up.
How do I find shared state in the server code?
AI coding tools sometimes write server code with a variable outside the handler. In a serverless host, one running instance can serve many requests. The variable then holds the data of the last user, and the next request can read it.
A bad pattern looks like this:
let currentUser: User | null = null
export async function GET(req: Request) {
if (!currentUser) {
currentUser = await getUserFromRequest(req)
}
return Response.json(currentUser)
}
The first request sets currentUser. Every other request on the same instance gets that user. The correct code reads the user in each request and keeps it in a local variable:
export async function GET(req: Request) {
const user = await getUserFromRequest(req)
return Response.json(user, { headers: { "Cache-Control": "private, no-store" } })
}
Search the code for these signs:
- A
letorvarat the top of a server file that holds a user, a session, or a token. - A memory cache, such as a
Map, that uses the path or the email as the key and returns a user. - A function that wraps a data call with a cache and does not include the user ID in the cache key.
- A framework cache option on a route that reads the session, such as a static or revalidated route.
For the last item, check the documentation of your framework. A route that reads the cookie must not be cached across users.
How do I stop a loose email match from picking the wrong user?
Many apps find the user with the email address from the token. This is correct only if the app treats the email as the same value every time. Two spellings of one address can create two accounts, or can match the wrong row.
- Normalize the email once. Trim spaces and change the address to lower case before each lookup and each insert.
- Use a unique index on the normalized email. The database then rejects a duplicate.
- Use the user ID after the first match. Store the ID in the session. Do not look up the user by email on each request.
- Do not read the email from the URL. A callback that takes
?email=from the query string can be made to sign in as any address. - Control account creation. The Supabase docs describe
shouldCreateUser: false, which stops the sign-in call from creating a new user for an unknown address. Use it if the app must only accept known people.
const email = rawEmail.trim().toLowerCase()
create unique index users_email_lower_key on public.users (lower(email));
If you already have duplicate accounts, merge them by hand and test the merge on a copy of the data. Do not merge accounts by email alone if the email was never confirmed, because a person can sign up with the address of another person.
How do I check the damage?
Find the sessions that the bug created. Then find out what each wrong session did.
- Find the time window. Look at the git history for the change that added the cache rule, the shared variable, or the new callback. The bug started at or after this change.
- Read your auth logs. Open the log of your auth provider or your own login log. List the sign-in events in the window. Match each event to the magic link request for the same address.
- Find mismatches. A sign-in event for user B that follows a link request for user A is a mismatch. Mark each one.
- Check the sessions. If your session table stores the IP address and the user agent, compare these values with the ones that the account owner normally uses.
- Check the actions. For each mismatch, read what the session did. Look at rows that were read, changed, or deleted, at emails that were sent, and at settings that changed.
- Check the cache logs. If your host or CDN shows cache hits for the login routes, count the hits in the window. Each hit is a response that the cache served to someone else.
- Tell the users. If the wrong account showed personal data, tell the affected people. Ask a lawyer or a security engineer about your legal duties.
You may not be able to prove who saw which account. Many apps do not log each page view. In this case, treat all users who signed in during the window as affected, and ask them to review their account.
How do I stop this from happening again?
- Mark all auth and session responses as private. Set
Cache-Control: private, no-storein one shared helper, and use it for each route that reads the session. - Put no user data in module-level variables. Add a lint rule or a code review check for
letvariables at the top of server files. - Write an automated test. Sign in as user A and as user B in two clients. Call the current user route in each client, and check that each returns its own ID.
- Use one-time tokens with a short life. Use the provider default, and check that a second click on the same link fails.
- Prefer a flow that ties the link to the browser. The Supabase docs say that magic links support PKCE when you pass an email. Read the docs of your provider for how the flow works.
- Add a sign-out step to the callback. The callback must replace any existing session.
- Scan the deployed app. A scan reads the cache headers of the real responses that your host sends. It finds a rule that the source code does not show.
For more on the design of these flows, read authentication implementation, auth flows for magic links, OTP, and reset, and cookie, TLS, and OAuth gaps.
When should I get help?
Get help in these cases:
- A user reports that they saw the personal data of another person.
- The cache logs show many hits on the login or session routes.
- You cannot find the code that picks the user, and the bug appears again after your fix.
- Your app stores payment data, health data, or other sensitive data in the accounts.
- You have users who signed in during the bug window, and you cannot tell which sessions are safe.
After the fix, run the two-window test and the curl header test again. Both tests must pass before you enable the magic link again.
Common questions
Is a magic link that opens the wrong account a security problem?
Can a CDN cache cause a magic link to open the wrong account?
Why does the link open the account that was already signed in?
Should I sign out all users after I find the bug?
Does one magic link work for more than one person?
Test your login flow
A VibeEval scan reads the cache headers of the login and session routes of your deployed app and reports responses that a shared cache can store.
14-day free trial · No credit card · Cancel anytime