skip to content

Beyond first sign-in, when must a server re-issue its session identifier, and what breaks the day a team turns that on?

level: middleimportance: should knowfreq 58%

answer

  1. not only at sign-in
  2. every privilege change, both directions
  3. entering and leaving an acting-as session
  4. the old value must stop resolving
  5. state keyed to the old identifier breaks

basics

~20 s

A server re-issues the session identifier at authentication and at every privilege change: a second factor accepted, a role elevated, an acting-as session entered or left. What breaks is state that was keyed to the identifier just replaced.

solid answer

~50 s

The framework-neutral rule is that the value in the cookie changes whenever the privileges behind it change, not only at first sign-in. An identifier that existed before the change is otherwise inherited afterwards, so anything that learned it — a value planted earlier, a value read off a shared console, a value sitting in a log — is still a valid reference to a session that now carries more authority. So re-issue when the second factor is accepted, when a role is elevated, and when an acting-as session is both entered **and** left. Mechanically the server writes a new record under a fresh identifier, copies across only the state it decided to carry, destroys the old record so the old value stops resolving, and sets the new reference on the response. What breaks on day one is everything else keyed to the old value: the anti-forgery token issued under it, a concurrent in-flight request still presenting it, and any server-side state the copy step quietly left behind.

code

pseudocode · 26 lines
pseudocode
# triggers: AUTHENTICATION, SECOND_FACTOR_ACCEPTED, ROLE_ELEVATED,
#           ACTING_AS_ENTERED, ACTING_AS_LEFT

function reissue(currentId, trigger, newPrivileges):
    old   = sessionStore.get(currentId)
    newId = mintIdentifier()                  # fresh, same entropy as the first

    carried = {}
    for key in CARRY_ACROSS:                  # an explicit list, not a copy-all
        carried[key] = old.state[key]

    createdAt = (trigger == AUTHENTICATION) ? clock.now() : old.createdAt
    #  an elevation does not restart the absolute clock: the sign-in was not repeated

    sessionStore.put(newId, {
        principal:  old.principal,
        privileges: newPrivileges,
        state:      carried,
        createdAt:  createdAt,
        lastSeenAt: clock.now()
    })
    sessionStore.delete(currentId)            # old value stops resolving now

    response.setSessionReference(newId)
    response.setAntiForgeryToken(issueTokenFor(newId))
    return newId

go deeper

for a junior

Recall that the value in the session cookie changes when privileges change, and that the previous value must stop working straight away. Naming sign-in and one other moment is enough at this level.

for a middle

Explain the boundary rule and the mechanics behind it: a new record under a new identifier, an explicit list of state carried across, and the old record destroyed in the same operation.

for a senior

Demonstrate you have shipped it — the anti-forgery token re-issued with the rotation, a client that retries the request that lost the race, and the absolute clock that survives an elevation instead of restarting.

for a principal

Frame it as an invariant the system holds rather than a step in one flow: every privilege boundary re-issues, including the way out of an elevated context, and anything keyed to the identifier has an owner responsible for moving it.

## The rule, stated once **The session identifier is re-issued at authentication and at every privilege change.** The reason is structural rather than anecdotal: the identifier is a bearer reference, so anyone holding it holds whatever the session can do. If the value does not change when the session's authority changes, then a value obtained while the session was anonymous — or while it was merely a signed-in operator rather than a supervisor — keeps working after the elevation and now buys the higher authority. Changing the value at the boundary means a reference captured on one side of it is worthless on the other. Note what the rule is *not*: it is not a statement about how any particular server spells the operation, and it is not only about the login form. The login case is the one everybody knows. The interesting half is everything after it. ## The moments that count as a privilege change 1. **Authentication itself** — the visitor becomes a named principal. 2. **A second factor accepted** — the half-finished login becomes a full one. The handle that carried the pending attempt must not be the handle that carries the authenticated session. 3. **Elevation to an administrative role** — a supervisor unlocks functions the invigilator on the same console cannot reach. 4. **Entering an acting-as session** — the operator begins acting on another principal's behalf. 5. **Leaving an acting-as session** — the same boundary in the other direction, and the one teams skip. A reference captured while the elevated context was live otherwise survives the exit, still pointing at a session whose shape has changed. A useful test for a sixth case: if the answer to "what may this session do?" is different after the event than before it, the event is a privilege change. ## What the server actually does at the boundary The operation is a move, not an edit: - mint a fresh identifier with the same unpredictability as the first one; - write a new record holding the new privileges and **only** the state you chose to carry across; - destroy the old record, so the previous value stops resolving immediately rather than lingering; - put the new reference on the response. The clocks deserve care here. The idle clock restarts anyway, because a request has just arrived. The **absolute** clock must not restart on a mid-session elevation: it counts from the authentication, and the authentication has not been repeated, so carrying `createdAt` across is what keeps a three-hour cap honest. If the trigger *is* a fresh authentication, that is when it legitimately resets. ## What breaks the day rotation is switched on Almost every bug report is the same shape — something was keyed to the value that has just been replaced: - **The anti-forgery token.** It was issued against the old session, so the first state-changing submission from an already-open form is rejected. The token has to be re-issued as part of the rotation. - **A concurrent in-flight request.** A client that fires two requests while the elevation is running has one of them arrive with the previous reference. It no longer resolves, so it fails, and the user reads "signed out while signing in". The fix is a client that retries once with the current reference — not keeping the old value alive, which would defeat the whole exercise. - **State the copy step forgot.** Whatever was hanging on the old record and was not explicitly carried is gone: a draft, a step counter in a multi-step flow, a selection. The copy is a decision list, not something that happens by itself. - **Anything else indexed by the identifier.** A per-session server-side cache, a rate-limit counter, a correlation key in the logs. The last one is worth planning for: a session that changes identity mid-life is harder to follow unless the records are deliberately linked. | Symptom after switching rotation on | What was keyed to the old identifier | |---|---| | First form submission after elevation is rejected | the anti-forgery token | | "Signed out" report during sign-in on a single-page client | a request in flight when the value changed | | A half-finished multi-step flow restarts | record state the copy step did not carry | | Two log entries that cannot be joined | the correlation key in the access records | ## Adjacent decisions that are not this rule Minting the first identifier — how many bits, and what signs the cookie — is a separate decision made once. *Which* guest data deserves to survive the change at sign-in is another, and it is a product question as much as a security one. Ending every session a person has, rather than replacing one, is a third mechanism entirely. This rule only says that the value changes at the boundary, that the old one stops working the moment it does, and that whatever was keyed to it has to move with it.

  • Why re-issue when leaving an acting-as session, not only when entering one?
    Because the identifier that named the elevated context otherwise outlives it. Anything that captured that value — a screenshot on a shared console, a log line, a second tab — still resolves to a session whose privileges have changed shape. Leaving is a privilege change like any other, and the boundary is only worth drawing if both sides of it are drawn.
  • A client fires two requests concurrently while the operator's role is being elevated. What do you do about the one that loses?
    Let it fail. It presents an identifier that no longer resolves, so the honest answer is a 401 Unauthorized and a client that retries once with the reference it now holds. Keeping the previous value valid for a grace period would mean two references authenticate the same person at two privilege levels, which is exactly what the rotation exists to prevent.
  • Should re-issuing the identifier restart the session's clocks?
    The idle clock restarts anyway, because a request has just arrived. The absolute clock should not: it counts from the authentication, so an elevation halfway through a sitting carries the original creation time across. Restarting it on every privilege change lets a session extend itself indefinitely by changing role, which quietly removes the cap.

saying these in an interview costs you the question

  • Re-issues the identifier at sign-in and nowhere else.
  • Keeps the old identifier valid so in-flight requests survive.
  • Updates the role on the record and leaves the identifier alone.
  • Assumes every attribute is carried across the rotation automatically.
  • Restarts the absolute clock at each elevation, extending the sign-in.