All posts
VibeZero Team24 min read

A perfectly random token that stopped nothing

The OAuth state parameter only stops login CSRF when it is bound to the browser that started the flow. Six real CVEs where the value was random anyway.

  • security
  • vibe coding
  • engineering

The best sentence ever written about the OAuth state parameter is in the immich security advisory for CVE-2025-43856, and it is an aside. The reporter is explaining their proof of concept, and notes that state=gibberish works fine, “because the one generated by the immich web app - while being unpredictable, as it should be - does not do anything.”

Unpredictable, as it should be. And useless.

That is the whole subject. Almost nobody ships an OAuth login with a guessable state value, because every tutorial says to use a random one and every library hands you a random one. What teams ship instead is a random value that is never compared, or compared against something an attacker can also write, or built out of a secret the flow then publishes into the URL. The randomness is the easy half and it is the half everyone gets right.

What login CSRF actually does to you

The confusing thing about this attack is that the victim is not logged into by an attacker. The victim is logged in as the attacker, which sounds like the attacker’s problem until you follow it through.

RFC 6749, section 10.12, states the consequence in one sentence and picks a good example: “A CSRF attack against the client’s redirection URI allows an attacker to inject its own authorization code or access token, which can result in the client using an access token associated with the attacker’s protected resources rather than the victim’s (e.g., save the victim’s bank account information to a protected resource controlled by the attacker).”

So: you believe you are in your own account. You upload a document, connect a payment method, paste an API key, type a private note. All of it lands in an account the attacker can log into whenever they like. It is a data exfiltration primitive that runs on the victim’s own hands, and the victim has no signal that anything is wrong, because the application is behaving perfectly.

immich makes the consequence worse, and its advisory is worth reading in full because the mechanism is entirely ordinary. immich uses /user-settings as a redirect URI, and that page “will automatically link the accounts if the user was already logged in.” The attacker starts an OAuth flow with their own Google account, blocks the final redirect on their own machine so the callback URL is never spent, and then delivers that URL to a victim, “hiding them behind url shorteners” or embedding it “in a (hidden) iframe on a website they control”. A logged-in victim who loads it links the attacker’s Google identity to the victim’s immich account. From then on, as the advisory puts it, “the attacker can log into the victims account using their own oauth credentials.”

That is the reverse of the usual direction. Ordinary login CSRF ends with the victim’s data inside the attacker’s account. immich’s variant ends with the attacker’s credential inside the victim’s account, which is takeover rather than surveillance, and it happens because the callback linked an identity instead of starting a session. GitHub’s advisory scores it high, 7.3, with base metrics CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N, and classifies it CWE-303, incorrect implementation of an authentication algorithm. It was fixed in 1.132.0.

If your app has a “connect your Google account” or “link GitHub” button anywhere in settings, that is the same shape. The account-linking endpoint is usually written by whoever was adding the integration, months after the login flow was reviewed.

The spec asked for it in the weakest words available

Here is the part that explains why this keeps happening. RFC 6749 defines the parameter in section 4.1.1 like this:

state

RECOMMENDED. An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in Section 10.12.

RECOMMENDED. SHOULD. Meanwhile, section 10.12 of the same document says “The client MUST implement CSRF protection for its redirection URI.”

The obligation is a MUST and the only mechanism offered for it is a SHOULD, in a parameter whose name and one-line description are about carrying application state. A developer reading the parameter list sees an optional field for round-tripping a value. A developer reading page 59 of the same document sees a mandatory security control. Most people read the parameter list.

Section 10.12 also says exactly what the value has to be, and this is the sentence the rest of this post is about: CSRF protection “is typically accomplished by requiring any request sent to the redirection URI endpoint to include a value that binds the request to the user-agent’s authenticated state (e.g., a hash of the session cookie used to authenticate the user-agent).”

Binds the request to the user-agent’s authenticated state. Not “is random.” Not “is unique.” The value has to be an answer to the question did this browser start this flow, and it has to be an answer only that browser could produce.

Four ways to have a state parameter and still lose

Each of these is a shipped CVE in software people run in production, and none of them is a randomness failure.

One: generated and never checked. immich, above. The web app minted a proper unpredictable value, sent it, and the callback handler did not compare it to anything. This is the most common form by a distance, because a value that is generated is visible in the code review and a value that is never read is not.

Two: generated once, for everybody. @fastify/oauth2, CVE-2023-31999: “All versions of @fastify/oauth2 used a statically generated state parameter at startup time and were used across all requests for all users.” The advisory then states the rule the library had broken, which is the same rule RFC 6749 states: “it should be unique per user and should be connected to the user’s session in some way that will allow the server to validate it.” NVD scores it 8.8 high (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H); GitHub rated the same bug medium, which tells you something about how consistently this class gets triaged. Note that the check here was not missing. The comparison ran, and passed, for every attacker, forever.

Three: carrying a URL instead of a token. Redash, CVE-2021-43777, whose advisory says it plainly: the Google login implementation “incorrectly uses the state parameter to pass the next URL to redirect the user to after login. The state parameter should be used for a CSRF token, not a static and easily predicted value.” One field, two jobs, and the job the field was named for won. NVD classifies it CWE-601, open redirect, while GitHub classifies it CWE-352, cross-site request forgery, and both are right: putting a destination in state gives you an open redirect and removes your CSRF defense in a single design decision. Redash’s fix is the one to copy: they moved to a library that generates and validates the token, and, in the advisory’s words, “The new implementation stores the next URL on the user session object.”

Four: reusing a secret you already had. Two flavors, both from 2026.

MISP’s Azure AD authentication plugin, CVE-2026-56425, used the PHP session identifier as the state value. The fix commit deletes one line, $url .= "state=" . session_id();, and its replacement carries a comment that is better security writing than most security writing. The state “MUST NOT be the session ID (or derived from it)” because “the session ID is itself a long-lived credential,” and the comment then lists where putting it in this URL would expose it: “browser history, Referer headers, and access/proxy logs along the redirect chain to and from Azure AD”. A control that was supposed to protect the callback was instead publishing a session cookie into every log on the route. NVD scores it 8.8 high on CVSS 3.1; the assigning authority scored it 9.3 critical on CVSS 4.0, which are different scales rather than a disagreement.

Notice what this case is not. The line that was deleted from the callback, } elseif (strcmp(session_id(), $_GET["state"]) == 0) {, does bind the response to the browser’s own session, which is the shape RFC 6749 asks for. The binding worked. What failed is the value it was built from: a credential the same design was publishing into logs, and one the CVE notes was not single-use either, which “weakened CSRF protections and increased the risk of replay attacks against the OAuth callback process.”

OpenClaw, CVE-2026-34511, did the 2026 version of the same mistake: its Gemini OAuth flow “reused the PKCE verifier as the OAuth state value. Because the provider reflected state back in the redirect URL, the verifier could be exposed alongside the authorization code.” PKCE exists so that capturing the code is not enough. Putting the verifier in a parameter designed to be echoed back means capturing the code is enough. Here too the verifier was a fine binding, held per flow by the client that started it. The advisory’s one-line description of the fix commit is the whole lesson in seven words: “separate OAuth state from the PKCE verifier”.

The fix for the 2023 bug became the 2026 bug

@fastify/oauth2 fixed CVE-2023-31999 in v7.2.0 by doing the obvious thing: “v7.2.0 changes the default behavior to store the state in a cookie with the http-only and same-site=lax attributes set. The state is now by default generated for every user.” Per-user, unpredictable, stored where the browser keeps it, compared on the way back. That is the textbook implementation.

On 2026-08-14 the same library published CVE-2026-18165, “Login CSRF via plantable OAuth state cookies”, against exactly that fix.

The mechanism is worth quoting at length, because its last sentence is the general statement of this entire post:

@fastify/oauth2 validates the OAuth state by comparing the callback state query parameter against a cookie it set at the start of the flow (and, with PKCE, the code_verifier from a second cookie). Both cookies use unprefixed names (oauth2-redirect-state, oauth2-code-verifier), so any party that can write a cookie for the application’s host can supply both values. The check only proves the query and cookie match, not that the same browser began the flow, so such a party can complete their own authorization flow inside a victim’s browser.

The check only proves the query and cookie match, not that the same browser began the flow. Two random values that agree with each other prove agreement, not origin.

Who can write a cookie for your host? The advisory lists it: applications are affected when “some host under their registrable domain is not fully under their control (for example a subdomain with an XSS, a subdomain takeover or dangling DNS record, or a forgotten staging host)”. The reason it matters is the sentence after it: “Cookies are scoped by host, not origin, and carry no integrity against related hosts (RFC 6265).” That is the same dangling-CNAME inventory that turns a wildcard entry in a redirect URI allowlist into an attacker-controlled callback. One forgotten subdomain, two unrelated-looking bugs.

Two more details from that advisory deserve to be read carefully. First: “Enabling PKCE does not help, because the verifier is read from a second cookie that can be planted the same way.” Second, the mitigation shipped in 8.3.0 is an opt-in flag, hostPrefixedCookies: true, and “The flag is off by default, so upgrading alone does not change behavior.” A dependency bump does not fix this one. Somebody has to read the release notes, which is a good argument for treating advisories on your auth libraries as work rather than as noise.

The advisory’s own preferred answer is the one this post ends up at: “The most robust option is to bind the state to the browser session server-side with generateStateFunction and checkStateFunction”.

Can you just use PKCE and skip state?

This is the modern version of the question, and the honest answer is yes, with conditions that are easy to miss.

RFC 9700, the current OAuth 2.0 security best current practice, section 2.1: “Clients MUST prevent Cross-Site Request Forgery (CSRF)… Clients that have ensured that the authorization server supports Proof Key for Code Exchange (PKCE) [RFC7636] MAY rely on the CSRF protection provided by PKCE. In OpenID Connect flows, the nonce parameter provides CSRF protection. Otherwise, one-time use CSRF tokens carried in the state parameter that are securely bound to the user agent MUST be used for CSRF protection (see Section 4.7.1).”

Read the conditions in order. The client must have ensured the server supports PKCE, which section 4.7.1 repeats as a MUST with a fallback: “Clients MUST ensure that the authorization server supports PKCE before using PKCE for CSRF protection. If an authorization server does not support PKCE, state or nonce MUST be used for CSRF protection.” And if you keep state around for application data, 4.7.1 adds an obligation to it: “If state is used for carrying application state, and the integrity of its contents is a concern, clients MUST protect state against tampering and swapping.”

PKCE genuinely is the stronger control where it applies. Section 4.7.1 says why: “PKCE provides robust protection against CSRF attacks even in the presence of an attacker that can read the authorization response (see Attacker (A3) in Section 3).” An attacker who reads a state value, by contrast, can replay it into a forged response.

But there is a trap, and RFC 9700 gives it a section of its own. Section 4.8, the PKCE downgrade attack, needs two preconditions, and the second one is the decision you just made: “The second prerequisite for this attack is that the client is not using state at all (e.g., because the client relies on PKCE for CSRF prevention) or that the client is not checking state correctly.” An attacker strips the code_challenge from an authorization request on their own device, the server issues a code bound to no challenge, the code gets injected into the victim’s flow, and the token endpoint never checks a verifier it was never given a challenge for.

The RFC’s countermeasure section is unusually candid about why it has to place the burden on the server: “Using state properly would prevent this attack. However, practice has shown that many OAuth clients do not use or check state properly. Therefore, authorization servers MUST mitigate this attack.” Which is standards-body language for: we asked, you did not, so we are fixing it upstream.

The practical reading for someone shipping an app: use PKCE, and keep a bound one-time state as well unless you have a reason not to. They cost the same thing, they fail differently, and RFC 9700 spends a section explaining what happens when you have only one of them.

What your managed backend already does with state

If you are on Supabase, GitHub or Google login does not run in your code at all, so it is worth knowing what the thing behind the button does. The answer is more interesting than the dashboard suggests, and it is readable in supabase/auth.

At the start of the flow, internal/api/external.go writes a row and uses the row’s primary key as the state:

// Always create flow state for all flows (both PKCE and implicit)
// The flow state ID is used as the state parameter instead of JWT
flowState, err := models.NewFlowState(flowParams)
if err != nil {
    return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid code_challenge_method").WithInternalError(err)
}
if err := db.Create(flowState); err != nil {
    return "", apierrors.NewInternalServerError("Error creating flow state").WithInternalError(err)
}

// Use the flow state ID as the state parameter (UUID format)
authURL := p.AuthCodeURL(flowState.ID.String(), authUrlParams...)

The id is uuid.Must(uuid.NewV4()) in internal/models/flow_state.go, so the state on the wire is a random version 4 UUID and nothing else. Everything that used to travel inside the parameter now lives in the row, and the struct still carries the receipt for that migration in a comment above its last five fields: “OAuth context fields (previously stored in JWT state parameter)”. Those fields are InviteToken, Referrer, OAuthClientStateID, LinkingTargetID and EmailOptional.

That is Redash’s fix, arrived at independently: application state moved out of the parameter and onto a server-side record, leaving an opaque handle behind. It also means the redirect target is frozen at the moment the flow starts rather than read from the callback, which is a quietly good property.

The callback checks, in loadExternalState and loadExternalStateFromUUID, are a decent list. Missing state is a 400, “OAuth state parameter missing”. A value that is not a UUID is “OAuth state parameter is invalid”. A row that is absent or past config.External.FlowStateExpiryDuration is “OAuth state not found or expired”, and that duration has a floor rather than a default: configuration.go defines defaultFlowStateExpiryDuration as 300 * time.Second and then raises any configured value that is lower, so five minutes is the shortest window available. Single use is enforced twice, once per flow type: the implicit branch runs tx.Destroy(flowState) after issuing tokens, and the PKCE branch re-reads the row ForUpdate inside the transaction and refuses it if UserID is already set, with the comment “Re-fetch with FOR UPDATE lock inside the transaction to prevent concurrent claims”.

Now hold that list against RFC 6749’s requirement, which is a value “that binds the request to the user-agent’s authenticated state”. Every check above is between the request and the database. None of them involves anything the browser holds. What they establish is that this flow was started through this project and has not been finished yet, which is a real and useful property and is not the same property as this browser started it.

The thing that supplies the missing half is PKCE, and on Supabase it is one option away. getCodeChallengeAndMethod in auth-js writes the verifier into the client’s own storage before the redirect:

const codeVerifier = generatePKCEVerifier()
let storedCodeVerifier = codeVerifier
if (isPasswordRecovery) {
  storedCodeVerifier += '/PASSWORD_RECOVERY'
}
await setItemAsync(storage, `${storageKey}-code-verifier`, storedCodeVerifier)
const codeChallenge = await generatePKCEChallenge(codeVerifier)

and _exchangeCodeForSession reads it back out on the way home, sending auth_code and code_verifier to /token?grant_type=pkce. A code produced by somebody else’s flow cannot be redeemed by your browser, because your browser does not hold the verifier that flow’s challenge was derived from. That is the binding, and it lives in exactly the right place.

The default is not that. DEFAULT_OPTIONS in GoTrueClient.ts still sets flowType: 'implicit', which is the same one-word change the redirect URI post arrives at from the other direction, for a different reason. Two independent arguments, one line:

export const supabase = createClient(url, anonKey, {
  auth: {
    // Under the 'implicit' default there is no code_verifier in this browser's
    // storage, so nothing in the callback is bound to this browser.
    flowType: "pkce",
  },
});

PKCE turns itself off on a page served over http

This one is computed rather than quoted, so here is the derivation.

The Web Cryptography API defines the Crypto interface, and marks subtle secure-context-only while leaving getRandomValues available everywhere:

[Exposed=(Window,Worker)]
interface Crypto {
  [SecureContext] readonly attribute SubtleCrypto subtle;
  ArrayBufferView getRandomValues(ArrayBufferView array);
  [SecureContext] DOMString randomUUID();
};

So on a page served over plain http, crypto exists and produces random bytes, and crypto.subtle does not exist. Note the carve-out, because it is the reason nobody catches this in practice. Secure Contexts returns “Potentially Trustworthy” for an origin whose “host matches one of the CIDR notations 127.0.0.0/8 or ::1/128”, and, separately, for one whose host is localhost or ends with .localhost. So loopback is a secure context and your development machine gets s256 no matter what. The degrade appears only once the app is served over http from somewhere else.

Now read auth-js’s challenge derivation against that:

export async function generatePKCEChallenge(verifier: string) {
  const hasCryptoSupport =
    typeof crypto !== 'undefined' &&
    typeof crypto.subtle !== 'undefined' &&
    typeof TextEncoder !== 'undefined'

  if (!hasCryptoSupport) {
    console.warn(
      'WebCrypto API is not supported. Code challenge method will default to use plain instead of sha256.'
    )
    return verifier
  }
  const hashed = await sha256(verifier)
  return btoa(hashed).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

The challenge becomes the verifier, and the caller then picks the method by comparing them: const codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256'. Running those two functions under Node with crypto.subtle removed prints the warning and reports method plain, with the challenge equal to the verifier; with crypto.subtle present the same code reports s256.

RFC 7636 section 7.2 says what plain costs:

With the “plain” method, there is a chance that “code_challenge” will be observed by the attacker on the device or in the http request. Since the code challenge is the same as the code verifier in this case, the “plain” method does not protect against the eavesdropping of the initial request.

And it names the assumption plain runs on: it “relies on the operating system and transport security not to disclose the request to an attacker.”

That is the circle. The condition that forces the downgrade, no transport security, is the exact condition under which the downgraded mode is unsafe. It is also OpenClaw’s CVE reached by configuration instead of by code: the verifier ends up in the URL either way.

This is why our free scan reports no-https-redirect as HIGH rather than as a hygiene note. Its finding text is about the visitor: “A visitor who types your domain without https, which is what most people do, stays on the unencrypted version of the site. The secure version existing does not help anyone who never reaches it.” The consequence reaches further than eavesdropping. A login flow on that page is a login flow whose strongest protection has silently turned itself off.

Writing the check yourself, if you own the callback

If you do own the callback, here is the shape. It is the same shape our own install flow uses, and every property in it is there to answer one question: did this browser start this flow?

The store is a table with a unique index on the hash:

create table oauth_flows (
  state_hash  text primary key,
  session_id  text        not null,
  expires_at  timestamptz not null,
  used_at     timestamptz
);

-- The claim. One statement, so replays and races lose deterministically
-- instead of racing a read against a write.
update oauth_flows set used_at = now()
where state_hash = $1 and used_at is null and expires_at > now()
returning session_id;

And the two halves of the flow:

import { createHash, randomBytes } from "node:crypto";

const STATE_TTL_MS = 10 * 60 * 1000;
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");

// Start a flow: the raw token goes to the authorization server, only its hash
// is stored, and the row records which session asked for it.
export function startFlow(store: Store, sessionId: string, now: number): string {
  const state = randomBytes(32).toString("base64url");
  store.insert({
    stateHash: sha256(state),
    sessionId,
    expiresAt: now + STATE_TTL_MS,
    usedAt: null,
  });
  return state;
}

// Finish a flow. Returns true only if this browser started this flow, and
// consumes the row on the way through. Unknown, expired, replayed and
// mismatched all return the same false: telling them apart tells an attacker
// which of the four they hit.
export function consumeFlow(
  store: Store,
  state: string | undefined,
  sessionId: string | undefined,
  now: number,
): boolean {
  if (typeof state !== "string" || !state) return false;
  if (typeof sessionId !== "string" || !sessionId) return false;
  const row = store.claim(sha256(state), now);
  if (!row) return false;
  return row.sessionId === sessionId;
}

Running that over a case matrix is more informative than reading it. The happy path passes. A replay of a consumed state fails. An attacker’s state presented in a victim’s browser fails, which is the whole point. An expired state, an absent state, an empty state, an invented state and a callback arriving with no session cookie all fail. And a retry after a mismatch also fails, because the row is consumed before the session is compared: one attempt per state, whoever makes it.

Four notes on the details, since each one is a decision rather than a style:

The sessionId is the pre-login session. A visitor clicking “sign in with Google” has no account session yet, so the value being bound to is an anonymous session cookie issued when the flow starts. Which means you have to be issuing one, and you have to rotate it after the login succeeds, or you have traded login CSRF for session fixation. MISP’s fix does both halves in one commit and its comment explains the ordering: the plugin can now call session_regenerate_id(true) after authentication, “Safe to do here since the state check above no longer depends on the session ID staying fixed.” Using the session id as the state is what had been blocking the rotation.

Only the hash is stored. The row is keyed by sha256(state), so a database read does not yield usable state tokens, and the same reasoning applies here as to a password reset ledger: the value in the table is not the value that opens the door.

The comparison is a plain === and that is fine here. MISP’s fix used PHP’s hash_equals, and that was correct for MISP, because their state was a session identifier, a secret being compared. Here the secret is 256 bits of CSPRNG output looked up by an index on its hash, and there is no partial match to walk toward. Constant-time comparison matters when an attacker can iterate; it does not manufacture entropy that is not there.

Failure has one message. Four different reasons, one answer. This is the same discipline that keeps a login form from becoming an account enumeration oracle.

If a cookie is genuinely the only place you can keep the value, the fastify advisory’s own workaround is the one to copy: use the __Host- prefixed names with secure: true and path: '/', which “switches the state and verifier cookies to their __Host- prefixed names, which browsers refuse to accept from a related host.”

What a scan can see, and what it cannot

The honest answer is narrower here than for most topics in this series, so it is worth being exact.

Nothing we run detects a missing or unbound state check. Not the free surface scan, not the runtime layer, not the SAST engines. The reason is not that nobody has written the rule. It is that the bug is an absence with no fixed shape: the comparison that should exist can live in a route handler, a middleware, a provider callback or a library configuration object, and the value it should be compared against can be a cookie, a session record or a database row. A rule that fired on “callback handler that does not compare state” would fire on every correct implementation that keeps the comparison one function away. This is a class where the five-layer scan reads your code, dependencies, secrets, configuration and running surface, and the finding still has to come from a human opening the callback file.

What the layers do reach:

  • Dependencies. Every CVE in this post is a version range. @fastify/oauth2 below 8.3.0 and immich below 1.132.0 are exactly what OSV-Scanner and Grype exist to report. That is also the layer that will not save you here, because 8.3.0’s mitigation is off by default: the advisory is the work, not the bump.
  • no-https-redirect, HIGH. For the reason worked through above, this finding is upstream of PKCE degrading to plain.
  • no-referrer-policy, LOW. RFC 9700 section 4.2.3 is direct about the consequence: “If the attacker learns state, the CSRF protection achieved by using state is lost, resulting in CSRF attacks as described in Section 4.4.1.8 of [RFC6819].” Our finding text names the same mechanism from the other end: “The full URL of the page a visitor was on is sent to every site they click through to, which leaks anything you keep in a path or a query string, such as reset tokens and internal identifiers.” A callback URL is a page with credentials in its query string. Checkable with the security headers checker.
  • The cookie findings. CVE-2026-18165 is a cookie-integrity bug, and cookie attributes are the one part of this whole subject that is observable from outside. The cookie security checker reads what your responses actually set.
  • Client configuration. The free scan reads the same-origin bundles, which is where createClient and its options end up. The tech stack checker and the Supabase key checker read the same material. A scan can see which authorization server you talk to. It cannot see what your callback compares.

The platform-specific starting points are in the Lovable and Bolt writeups, and the pattern there is the one this post predicts: generated apps get social login from a client library, the client library handles state on your behalf, and the setting that decides whether anything is bound to your browser is a default nobody chose.

A token is not a binding

The reason this bug outlives every generation of OAuth advice is that the parameter looks like plumbing. It is called state. Its one-line definition in the spec is about round-tripping a value. Libraries fill it in for you. The code that generates it looks obviously correct, because it is obviously correct, and the code that fails to check it does not exist and therefore cannot be read.

So audit the other end. Open the callback handler and find the line where the returned value is compared, then ask what it is being compared against, and whether an attacker could have written that too. A constant fails. A cookie on a domain you share with a forgotten staging host fails. A row in your database proves the flow exists rather than that it is yours. A value only this browser could hold is the one that answers the question, and if that value is a credential you already had, you have moved the problem rather than solved it.

That is the whole test, and it is the same test MFA and token expiry turn out to be versions of. The credential is not the interesting part. What the credential is tied to is.

ShareXLinkedIn