Short answer: for a server-rendered learning app, create a short-lived session only after the phone code is verified, keep refresh as a separate state transition, and make recovery a deliberate path rather than an accidental logout loop. The useful design artifact is an auditable session record tied to a learner, device context, and recovery status.
I build RAG and agent features in Python, so I tend to move from a notebook test to a production boundary quickly. Authentication deserves a slower handoff. In an edtech app, a learner may lose a phone while a parent, teacher, or school administrator still needs a safe way to recover the account. The browser should receive only an opaque session cookie; the server owns the lifecycle and records why each transition happened.
Treat the four actions as different state changes. Code verification proves possession of a phone channel. Session creation establishes a browser session. Verification checks whether that session is still active. Refresh extends a valid session under a stricter policy. Logout revokes one session, while an account-recovery event may need to revoke every session.
That separation makes failure visible. A refresh request must not silently create a new account. A logout request must not be interpreted as proof that the phone number is still controlled. For a school district, the audit trail should answer: which learner was affected, which session changed, what policy allowed it, and when the change took place.
The request flow is intentionally plain:
Keep it boring.
The browser never needs to know whether the session store is SQL, Redis, or another service. It needs stable cookie behavior and a clear response when the session is no longer valid.
The following example is a transport adapter, not a complete identity provider. It keeps endpoint knowledge in one place, sends credentials from the server process, and makes the application decide when a transition is allowed. The paths are the four lifecycle operations used by this design.
import json
import os
import urllib.error
import urllib.request
AUTH_BASE = os.environ["AUTH_BASE_URL"].rstrip("/")
AUTH_TOKEN = os.environ["AUTH_SERVICE_TOKEN"]
def auth_request(path: str, method: str, payload: dict | None = None) -> dict:
body = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
f"{AUTH_BASE}{path}",
data=body,
method=method,
headers={
"Authorization": f"Bearer {AUTH_TOKEN}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=8) as response:
return json.load(response)
except urllib.error.HTTPError as error:
raise RuntimeError(f"authentication transition rejected: {error.code}") from error
def create_session(learner_id: str, verified_phone: str) -> str:
result = auth_request(
"/v1/auth/session/create",
"POST",
{"user_id": learner_id, "verified_phone": verified_phone},
)
return result["session_id"]
def verify_session(session_id: str) -> bool:
result = auth_request(
f"/v1/auth/session/verify/{session_id}",
"GET",
)
return bool(result.get("valid"))
def refresh_session(refresh_transition, refresh_token: str) -> dict:
return refresh_transition(
refresh_token=refresh_token,
idempotency_key=os.urandom(12).hex(),
)
In a real server-rendered framework, create_session
runs after the one-time-code verifier has consumed the code. Set the cookie with Secure
, HttpOnly
, and an appropriate SameSite
policy; rotate it after login so an attacker can't reuse a pre-login identifier. A CSRF token still matters for state-changing form posts. OWASP's authentication guidance also calls for generic account-recovery responses, rate limits, and careful session invalidation.
I keep the adapter this small because my eval harness can exercise it without a browser. The test fixture includes a normal learner, an expired session, a replayed refresh token, and a lost-phone recovery. Your mileage may vary on cookie lifetime: younger learners using shared school devices may need shorter idle windows than staff using managed laptops.
For transient service limits, retry a 429 with bounded exponential backoff and jitter, and stop after the request deadline. Every create, refresh, or revoke write needs an application idempotency key so a retry cannot create a second active session or apply the same recovery action twice.
Expiry is not recovery. A 30-minute idle timeout limits exposure, but it does not help a learner whose phone was destroyed in a flood. Recovery should require a different verified factor or an approved school workflow, then revoke sessions according to the risk decision.
For example, a district can require a guardian email link for a minor account, while a staff account may require an administrator approval. The application should store the recovery case ID beside the session events, not put sensitive evidence in a browser cookie. If no second factor exists, say so plainly and route the case to human review; do not weaken the session check to make the support ticket disappear.
One subtle trap is refresh-token reuse. If a refresh operation succeeds, mark the old refresh credential as spent and bind the replacement to the same account and device policy. If a spent credential appears again, treat it as a recovery signal and revoke the affected session family. That policy is more useful than endlessly extending a cookie that may already be copied.
The failure matrix below is the minimum I would put in an eval notebook before shipping. It tests decisions, not just HTTP status codes.
| Event | Required decision | Audit fields |
|---|---|---|
| Correct code, new browser | Create one session and rotate the cookie | learner ID, verification time, device hint |
| Expired session | Deny protected data and offer login | session ID, expiry reason |
| Refresh after idle limit | Require fresh phone verification | old session, policy version |
| Current-device logout | Revoke only that session | session ID, actor, timestamp |
| Lost phone recovery | Revoke the session family or all sessions | recovery case, approving role |
| Replay of spent refresh credential | Revoke affected sessions and alert | credential family, correlation ID |
The table is intentionally explicit. “Logout worked” is not a sufficient test assertion if another browser remains active after a lost-phone report.
Start with a deterministic fixture set. I use Python tests to assert that an unauthenticated request cannot load a lesson, that a verified session can load it, and that revocation takes effect on the next protected request. Then I add property-style cases: refreshing twice cannot produce two active replacements, and logging out one device does not revoke another unless the recovery policy says so.
Measure the transitions that affect learners: code-to-session latency, verification rejection rate, refresh rejection rate, and time from recovery approval to global revocation. Avoid logging phone numbers, codes, raw cookies, or full lesson URLs with identifiers. A correlation ID and a hashed internal account key are enough for most investigations.
The catch is that this architecture is not suitable when the product cannot operate a server-side session store or needs a completely offline login. In those cases, a managed identity system or a local device credential may be a better fit, with its own recovery rules. Stick with a simpler signed-cookie design only when its revocation and recovery semantics are demonstrably acceptable; portability is less important than being able to end access when a phone is lost.
I am not sure a single expiry number can ever be universal. Classroom schedules, minor-protection requirements, and staff workflows pull in different directions. What resolves that uncertainty is an observed risk review backed by the transition tests, not a default copied from a sample app.
Before release, I check the server-side key boundary, cookie flags, CSRF coverage, code-verification handoff, refresh rotation, single-session logout, global recovery revocation, generic error text, and the audit retention policy. I run the fixture suite in CI and keep the prompt and test data versioned beside the application, because an authentication change that passes a happy-path browser demo can still break account recovery.