-
Notifications
You must be signed in to change notification settings - Fork 0
Sessions
Present the credential either way:
Cookie: dd_session=dds2.… # browser
Authorization: Bearer dds2.… # CLI, scripts, services
The dds2. prefix distinguishes a session from a raw DoorDash token; sending
the latter gets a 401 that says so explicitly.
Measured against the live API, DoorDash access tokens last 72 hours and come
with a refresh token that rotates on every use, with the previous value
rejected immediately (bun run inspect-token reproduces this).
That rotation is why the tokens cannot live on the client. If a response carrying a rotated token were ever lost — a dropped connection, a client crash mid-write — DoorDash would have already rotated, the new token would exist only in that lost response, and the session would be permanently dead. So tokens are held server-side in SQLite, where they can be updated durably.
When a request arrives with an access token within SESSION_REFRESH_SKEW_SECONDS
of expiry, the server renews it inline. Your credential does not change, so
there is nothing to store and no header to watch: the per-session key that
decrypts the row lives in the credential and never rotates, while only the row
contents are rewritten.
Concurrent requests are coalesced onto a single renewal — without that, ten parallel requests would each spend the same refresh token and nine would get a 401. That coalescing is per-process, so do not run multiple instances against one SQLite file; that needs a shared lock (Redis) instead.
If a renewal is refused the chain is broken for good, so the session is deleted
and the response is 401 {"error":"session_expired"} pointing at a fresh login.
Measured against the live API:
| Access token lifetime | 72h (expires_in 259200) |
| Refresh token | Rotates on every use; previous value 401s immediately |
| Absolute cap on the chain | None found |
The last row is the important one. Access-token claims carry orig_iat
("original issued at") and no plain iat — a claim that only needs to exist if
something is measured from the first authentication, which is how a maximum
refresh window is usually enforced. But orig_iat moves forward on every
refresh, so each renewal mints a fresh 72h window anchored to now rather than
to the original login. Nothing ties the chain back to when you signed in.
So a session in regular use renews indefinitely, and SESSION_MAX_AGE_SECONDS
is a policy choice rather than a technical limit — how long should a leaked
credential stay usable, given that POST /v1/auth/logout can revoke it anyway?
The 30-day default is deliberately conservative; raise it freely.
Two caveats. This is inference from claims, not a guarantee: DoorDash could
enforce a cap server-side that the claims do not reflect. And how long an
unused refresh token survives is still unmeasured, which is what
SESSION_IDLE_TIMEOUT_SECONDS (14 days) hedges against. Either way the failure
mode is one browser login.
bun run inspect-token reproduces all of the above in a single run.
Measuring it yourself over time (rarely needed)
scripts/probe-refresh-lifetime.ts measures refresh-token lifetime empirically,
over real elapsed time. Now that the claims answer the absolute-cap question,
its only residual use is bounding the idle timeout, or confirming that no
server-side cap exists that the claims fail to show.
A probe consumes the token — a successful refresh rotates it and resets the idle clock — so each success is both a data point and the token for the next probe.
| Probe | Question | Method |
|---|---|---|
idle |
How long can a token sit unused? | Gap doubles after each success (1d, 2d, 4d…) until refused |
sustained |
Does a regularly-used chain die anyway? | Refreshes daily; a failure means a cap the claims hid |
Use a dedicated login for each — the probe rotates the token it holds, so sharing one with a live session would break both.
bun run probe-refresh init idletick only acts when a probe is due, and records the gap that actually
elapsed rather than the one scheduled, so a missed run or a sleeping machine
skews nothing:
(crontab -l 2>/dev/null; echo "0 * * * * cd $PWD && ~/.bun/bin/bun run probe-refresh tick idle") | crontab -bun run probe-refresh status idleState lives in ./data/refresh-probe-*.json, written 0600 because it holds a
live credential, and gitignored.
Each session gets its own random data key. Only ciphertext goes in the database; the key exists solely inside the client's credential:
dds2.<base64url( session_id[16] || data_key[32] )>
A dump of sessions.db therefore decrypts to nothing on its own. Compromising a
session still requires the client's credential, exactly as with any cookie.
SESSION_KEYS no longer protects sessions — it now covers only short-lived
sealed values: the login ticket, and the two pairing tickets below. It remains
an ordered list: first key seals, all decrypt, so prepend a new key to rotate.
Pairings get the same split-key treatment where it fits and a documented
exception where it does not. The device code is the same shape as a session
credential (ddp1.<id||key>) and only sha256(key) is stored, so a dump of the
pairings table yields no usable device code. The session credential waiting to
be collected cannot work that way — the browser doing the approving has never
seen the device code, so it has no key to encrypt to. It is sealed under
SESSION_KEYS instead, for the few minutes between approval and collection,
and the row is deleted the moment the device picks it up. Reading that table is
therefore not enough on its own; it also takes SESSION_KEYS, which is the same
boundary login tickets already rely on.
CSRF. Cookie-authenticated writes require a trusted Origin. Bearer-
authenticated requests are exempt — a cross-site page cannot set an
Authorization header without a CORS preflight it will not pass.
| Variable | Default | Meaning |
|---|---|---|
SESSION_MAX_AGE_SECONDS |
2592000 (30d) |
Hard end of a session, regardless of renewals. The only thing that forces a new browser login. |
SESSION_IDLE_TIMEOUT_SECONDS |
1209600 (14d) |
Drop a session unused for this long. Must be shorter than the cap or it can never fire — the server warns at startup if it cannot. |
SESSION_REFRESH_SKEW_SECONDS |
300 (5m) |
Renew once the access token is this close to expiring. |
SESSION_SWEEP_INTERVAL_SECONDS |
3600 (1h) |
How often expired rows are deleted. |
SESSION_DB_PATH |
./data/sessions.db |
Where sessions live. |
Handy values: 604800 = 7d, 2592000 = 30d, 7776000 = 90d, 31536000 = 365d.
Non-positive values are rejected at startup rather than producing sessions that
are dead on arrival. The effective policy is printed on boot, since .env is
loaded automatically and a stale file silently overrides the defaults:
Session policy:
max age 30d (SESSION_MAX_AGE_SECONDS=2592000)
idle out 14d (SESSION_IDLE_TIMEOUT_SECONDS=1209600)
renew at 5m before token expiry (SESSION_REFRESH_SKEW_SECONDS=300)
Revocation is real. POST /v1/auth/logout deletes the row, so every copy of
that credential stops working immediately. Sessions also expire on their own via
SESSION_MAX_AGE_SECONDS (hard deadline) and SESSION_IDLE_TIMEOUT_SECONDS
(unused for too long), swept periodically.