Sessions
Checking who's logged in, listing active sessions (paginated and not), and revoking them — one, several, or all. curl, Go, and a browser client.
Sessions
The self-service surface on top of the two-token session model: checking whether a request is authenticated, listing a user's own active sessions, and revoking them. For the token/cookie mechanics themselves (rotation, hashing, the grace window) see Security concepts and the sessions table on Schemas. For an admin viewing or revoking another user's sessions, see Admin — that's a different, role-gated surface and isn't covered here.
Configuration
Two options govern everything on this page: SessionConfig sets the lifetimes that decide when the endpoints below actually have something to show, and CookieConfig sets the cookie names every curl example on this page reads and writes.
goauth.WithSession(goauth.SessionConfig{
TTL: 30 * 24 * time.Hour, // optional, default 30d
IdleTTL: 7 * 24 * time.Hour, // optional, default 7d
RefreshTokenTTL: 30 * 24 * time.Hour, // optional, default 30d
TokenTTL: time.Hour, // optional, default 1h
MaxLifetime: 0, // optional, default 0 (no limit)
GraceWindow: goauth.Duration(10 * time.Second), // optional, default 5s
TouchDebounce: goauth.Duration(0), // optional, default 5m — 0 turns it off
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
TTL | time.Duration | Optional | 30d | Absolute hard expiry — the expiresAt on every session in the list responses above. |
IdleTTL | time.Duration | Optional | 7d | Timeout since last activity. Must not exceed TTL. |
RefreshTokenTTL | time.Duration | Optional | 30d | Must not be less than TTL. Governs how long the transparent refresh described in the curl note above keeps working after the session itself expires. |
TokenTTL | time.Duration | Optional | 1h | Lifetime of verification and password-reset tokens. |
MaxLifetime | time.Duration | Optional | 0 — no limit | If set, must be >= TTL. |
GraceWindow | *time.Duration | Optional | 5s | Window a just-rotated refresh token is still accepted, so two requests racing to refresh don't log the user out. nil (leave the field out) means "use the default"; set it with goauth.Duration(...) — goauth.Duration(0) turns it off. |
TouchDebounce | *time.Duration | Optional | 5m | Minimum interval between last_active_at writes — what makes lastActiveAt in the list responses a few minutes stale rather than exact. Same nil-vs-pointer rule as GraceWindow; goauth.Duration(0) writes on every authenticated request. |
GraceWindow and TouchDebounce are *time.Duration, not time.Duration, specifically so "left unset" (nil) and "explicitly off" (a pointer to 0) can't collide the way they would on a plain value field.
Expected errors
session_ttl must be positivesession_idle_ttl must be positivesession_idle_ttl must not exceed session_ttlrefresh_token_ttl must be positiverefresh_token_ttl must not be less than session_ttlsession token_ttl must be positive— only reachable via an explicit negative value; an omitted one defaults to1h.session grace_window must not be negative (use goauth.Duration(0) to turn it off)— hint: you passed a negative duration.session touch_debounce must not be negative (use goauth.Duration(0) to turn it off)— same hint.session max_lifetime must not be negative (0 = no limit)session max_lifetime must not be less than session_ttl— hint: only checked whenMaxLifetimeis set above zero.
goauth.WithCookie(goauth.CookieConfig{
Name: "goauth_session", // optional, default "goauth_session"
RefreshName: "goauth_refresh", // optional, default "goauth_refresh"
Domain: "", // optional, default "" (host-only cookie)
Path: "/", // optional, default "/"
SameSite: http.SameSiteLaxMode, // optional, default Lax
Secure: goauth.SecureAlways(), // optional, default derived from Environment/BaseURL
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Name | string | Optional | goauth_session | The cookie name every curl example on this page passes via -b/-c. Change it here and the examples change with it. |
RefreshName | string | Optional | goauth_refresh | The cookie AuthMiddleware reads for the transparent refresh. |
Domain | string | Optional | "" — host-only cookie | |
Path | string | Optional | / | |
SameSite | http.SameSite | Optional | Lax | |
Secure | *bool | Optional | derived — true unless Environment is dev and BaseURL is http:// | Tri-state: nil derives it correctly for dev and prod. Override with goauth.SecureAlways() or goauth.SecureNever() (local http:// only). |
No field on CookieConfig reaches validation as an error — every field is defaulted before the config is checked.
Full reference for both, including how they interact with everything else: Configuration → WithSession and Configuration → WithCookie.
Writing your own login handler
a.Services.Auth.Login (and Register) return raw tokens — no cookies are set, since the service layer never touches net/http. If you're writing a custom handler (a different response shape, extra steps before the session is issued) instead of using the built-in a.Handlers.Login, use these three methods to reproduce the exact cookie behavior the built-in handlers use:
func myLoginHandler(a *goauth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
result, err := a.Services.Auth.Login(r.Context(), goauth.LoginInput{
Email: r.FormValue("email"),
Password: r.FormValue("password"),
})
if err != nil {
// handle err
return
}
a.SetSessionCookies(w, result.SessionToken, result.RefreshToken)
a.RotateCSRFToken(w) // optional — matches the built-in handlers' behavior
// ... your own response shape
}
}SetSessionCookies(w, sessionToken, refreshToken)writes both cookies using the configuredCookieConfig/SessionConfigsettings. Pass""forrefreshTokento skip the refresh cookie — the same guard the built-in handlers use.ClearSessionCookies(w)expires both cookies — in a custom logout handler, call it only aftera.Services.Session.Revokesucceeds. A revocation error means the server-side session may still be valid and must be returned to the client instead of presenting a false-success logout.RotateCSRFToken(w)issues a fresh CSRF token cookie. It's a separate call, not bundled intoSetSessionCookies, because CSRF rotation is also needed on flows that don't touch cookies at all (e.g. password change) — keeping them separate means one method does one thing. It's a no-op ifSecurityConfig.DisableCSRFTokenis set.
Frontend client setup
The Client examples below call the same apiRequest helper defined on Client → Setup — see that page (or Authentication) if you haven't already.
A note on curl and sessions — read this if something isn't working
Every endpoint on this page needs the actual session cookie, and curl doesn't manage cookies automatically the way a browser does. Two things cause almost all of the confusion:
1. -b and -c need to point at the same file, on every request — not just login.
# Log in and create the jar
curl -X POST https://api.myapp.com/auth/login \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-c cookies.txt \
-d '{"email":"ada@example.com","password":"..."}'
# Every later request: read AND write the same file
curl https://api.myapp.com/auth/sessions -b cookies.txt -c cookies.txtAuthMiddleware transparently rotates your session when the session cookie has expired but the refresh cookie is still valid — it issues new Set-Cookie headers on that same response rather than erroring. If you only ever read from the jar (-b) and never let curl write back to it (-c), you keep replaying the old token, and once the refresh window closes too you'll get a 401 session_expired that looks like it came from nowhere. Always pass both flags pointing at the same file.
2. The revoke endpoints also need the Origin header. DELETE /auth/sessions/{id}, POST /auth/sessions/revoke, and DELETE /auth/sessions are state-changing, so they run through the same OriginCheck as register/login — see Authentication → Origin checking. The two list endpoints and GET /auth/me are GET requests and don't need it; OriginCheck only inspects POST/PUT/PATCH/DELETE.
Is this secure?
Yes — curl isn't bypassing anything here, it's just making manual what a browser normally hides. The session cookie is the actual credential: it's a random token, stored server-side only as a SHA-256 hash (see Schemas → sessions), never guessable from the session ID. Every revoke query is scoped with WHERE user_id = ... AND id = ..., so knowing or guessing another session's ID never lets you touch it — RevokeSession returns the same 404 session_not_found whether the ID doesn't exist or just isn't yours. curl can do everything a browser can because it has the same cookie, not because any check was skipped.
Checking who's logged in
One endpoint. GET /auth/me requires auth and answers 401 when there is no valid session, so code that renders differently for anonymous visitors reads that status as "logged out" rather than as a failure:
try {
setUser(await authApi.me())
} catch {
setUser(null) // a 401 here just means "not logged in"
}That 401 is worth more than a 200 with a null body would be. It comes from AuthMiddleware, which is also what transparently renews an expired-but-refreshable session before the handler runs. An endpoint that answered softly would have to sit outside that middleware, and would then report "logged out" for a session that was one refresh cookie away from being valid.
GET /auth/me — Auth required
Returns the authenticated user plus whether they have a password set (relevant for OAuth-only accounts, which can be null-password).
{
"id": "b3f1...",
"email": "ada@example.com",
"name": "Ada Lovelace",
"role": "user",
"isVerified": true,
"isBanned": false,
"orgOwnerCount": 0,
"createdAt": "2026-08-09T12:00:00Z",
"updatedAt": "2026-08-09T12:00:00Z",
"hasPassword": true,
"session": {
"id": "9c2e...",
"userId": "b3f1...",
"ipAddress": "203.0.113.4",
"userAgent": "Mozilla/5.0 ...",
"parsedUA": { "browser": "Chrome", "os": "Windows", "deviceType": "desktop" },
"isRevoked": false,
"expiresAt": "2026-09-08T12:00:00Z",
"refreshExpiresAt": "2026-09-13T12:00:00Z",
"createdAt": "2026-09-06T12:00:00Z",
"lastActiveAt": "2026-09-06T12:31:00Z",
"activeOrgId": "org_7fa2...",
"activeOrgRole": "admin"
}
}session is the caller's own current session — the same object GET /auth/sessions returns for each entry, plus the active-org fields. Every token hash on it is json:"-", so nothing usable as a credential is in there.
It is what lets a client read the active org — activeOrgId/activeOrgRole — which is otherwise not exposed anywhere: PUT/DELETE /auth/orgs/active only return a bare {"message"}.
Which fields you actually get:
| Field | When it appears |
|---|---|
id, userId, isRevoked, expiresAt, createdAt | Always. isRevoked is always false here — a revoked session never gets past AuthMiddleware to reach this handler. |
ipAddress, userAgent, parsedUA | When they were recorded at login. parsedUA is derived from userAgent, so the two travel together. |
refreshExpiresAt | Whenever a refresh cookie was issued — in practice, always for a browser login. |
refreshRotatedAt | Only after the refresh token has rotated at least once. |
lastActiveAt | Once the session has been touched (subject to TouchDebounce). |
activeOrgId, activeOrgRole | Only while an org is active. Both absent otherwise — not null. |
revokedAt | Never on this endpoint; see isRevoked above. |
One session shape, everywhere
session is domain.Session verbatim, minus the three json:"-" token hashes. It is the same object POST /auth/login, POST /auth/register, POST /auth/invite/register, POST /auth/2fa/verify, POST /auth/verify-email and POST /auth/refresh all return under that key. Read the fields you need and ignore the rest: a field added to the struct appears on every one of them at once.
| Code | Status | Cause |
|---|---|---|
session_expired | 401 | No session cookie, or one that expired with no usable refresh cookie |
unauthorized | 401 | Session cookie doesn't validate (bad signature, not found, user deleted) |
user_banned | 403 | Account is banned — surfaces here even mid-session, during the transparent-refresh path |
curl
curl https://api.myapp.com/auth/me -b cookies.txt -c cookies.txtProgrammatic (Go)
There's no dedicated "get me" service method — the HTTP layer reads the user already attached to the request context by AuthMiddleware. Calling this directly in Go means you already have the user from wherever you got it (e.g. middleware.GetUserFromContext(ctx) inside your own handler), so there's nothing extra to call.
Client
const me = await apiRequest(API_BASE, "GET", "/auth/me");Refreshing tokens
POST /auth/refresh — Refresh cookie required
Rotates both the session token and refresh token. The refresh token comes from the cookie, not the body. On success, new Set-Cookie headers are issued for both tokens. On failure, both cookies are cleared.
This is what AuthMiddleware calls transparently when the session cookie has expired but the refresh cookie is still valid — but you can also call it explicitly if you want to force a rotation.
{
"session": {
"id": "9e2c...",
"userId": "b3f1...",
"ipAddress": "203.0.113.4",
"userAgent": "Mozilla/5.0 ...",
"isRevoked": false,
"expiresAt": "2026-09-08T12:00:00Z",
"refreshExpiresAt": "2026-09-13T12:00:00Z",
"createdAt": "2026-08-01T09:00:00Z",
"lastActiveAt": "2026-09-06T12:31:00Z"
}
}| Code | Status | Cause |
|---|---|---|
invalid_refresh | 401 | No refresh cookie, or one that expired / was revoked |
curl
curl -X POST https://api.myapp.com/auth/refresh \
-H "Origin: https://myapp.com" \
-b cookies.txt -c cookies.txtProgrammatic (Go)
result, err := auth.Services.Session.Refresh(ctx, refreshToken)
// result.SessionToken / result.RefreshToken are the raw values — set them as cookies yourselfClient
await apiRequest(API_BASE, "POST", "/auth/refresh");
// new cookies are set automatically by the browserFor explicit refresh usage, see Client → Refresh tokens.
Listing sessions
Both endpoints return the caller's own sessions only, plus currentSessionId so the UI can highlight the device in use and disable its own revoke button.
Paginated — GET /auth/sessions
Query params
| Param | Type | Default | Notes |
|---|---|---|---|
offset | int | 0 | |
limit | int | 20 | Clamped to a max of 100 — a larger value is silently capped, not rejected |
{
"sessions": [
{
"id": "9e2c...",
"userId": "b3f1...",
"ipAddress": "203.0.113.4",
"userAgent": "Mozilla/5.0 ...",
"parsedUA": { "browser": "Chrome", "os": "Windows", "deviceType": "desktop" },
"isRevoked": false,
"expiresAt": "2026-09-08T12:00:00Z",
"refreshExpiresAt": "2026-09-13T12:00:00Z",
"lastActiveAt": "2026-08-09T11:55:00Z",
"createdAt": "2026-08-01T09:00:00Z"
}
],
"total": 3,
"limit": 20,
"offset": 0,
"currentSessionId": "9e2c..."
}| Code | Status | Cause |
|---|---|---|
session_expired / unauthorized | 401 | Same as Checking who's logged in above |
internal_error | 500 | Database failure |
curl
curl "https://api.myapp.com/auth/sessions?offset=0&limit=20" -b cookies.txt -c cookies.txtProgrammatic (Go)
sessions, total, err := auth.Services.Session.List(ctx, userID, 0, 20)Client
const { sessions, total, currentSessionId } = await apiRequest(
API_BASE, "GET", "/auth/sessions?offset=0&limit=20"
);For a complete session list UI with pagination, see Client → Sessions.
All at once — GET /auth/sessions/all
No query params, no pagination — every active session in one response. Same per-session shape as above, just without total/limit/offset.
{
"sessions": [
{ "id": "9e2c...", "userId": "b3f1..." }
],
"currentSessionId": "9e2c..."
}Errors are the same as the paginated endpoint.
curl
curl https://api.myapp.com/auth/sessions/all -b cookies.txt -c cookies.txtProgrammatic (Go)
sessions, err := auth.Services.Session.ListAll(ctx, userID)Client
const { sessions, currentSessionId } = await apiRequest(API_BASE, "GET", "/auth/sessions/all");For a complete session list UI, see Client → Sessions.
Revoking sessions
All three endpoints are user-scoped — every query includes WHERE user_id = ..., so there's no way to revoke a session that isn't yours, even with a valid-looking ID.
Revoke one — DELETE /auth/sessions/{id}
id is the session's own ID (the id field from the list responses above), not the raw session token.
{ "message": "Session revoked" }| Code | Status | Cause |
|---|---|---|
session_not_found | 404 | ID doesn't exist, or exists but isn't yours — the two cases are indistinguishable on purpose |
internal_error | 500 | Database failure |
curl
curl -X DELETE https://api.myapp.com/auth/sessions/9e2c... \
-H "Origin: https://myapp.com" \
-b cookies.txt -c cookies.txtProgrammatic (Go)
revoked, err := auth.Services.Session.RevokeByIDForUser(ctx, sessionID, userID)
if err == nil && !revoked {
// treat as session_not_found — it existed but wasn't this user's, or didn't exist at all
}Client
await apiRequest(API_BASE, "DELETE", `/auth/sessions/${sessionId}`);For a complete revoke button, see Client → Sessions.
Revoke several — POST /auth/sessions/revoke
Body: { "sessionIds": ["...", "..."] } — 1 to 100 IDs. IDs that don't exist or don't belong to the caller are silently skipped rather than erroring individually.
{ "revoked": 2 }revoked is the count actually deleted, which can be less than the number of IDs you sent — that's not an error, it just means some IDs didn't match.
| Code | Status | Cause |
|---|---|---|
invalid_input | 400 | sessionIds empty, or more than 100 entries |
invalid_json | 400 | Malformed body |
internal_error | 500 | Database failure |
curl
curl -X POST https://api.myapp.com/auth/sessions/revoke \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-b cookies.txt -c cookies.txt \
-d '{"sessionIds":["9e2c...","a71f..."]}'Programmatic (Go)
revoked, err := auth.Services.Session.RevokeManyForUser(ctx, []string{"9e2c...", "a71f..."}, userID)Client
const { revoked } = await apiRequest(API_BASE, "POST", "/auth/sessions/revoke", {
sessionIds: [id1, id2],
});For a complete session list with revoke, see Client → Sessions.
Revoke all but current — DELETE /auth/sessions
No body. Revokes every one of the caller's sessions except the one making the request — the classic "log out everywhere else" button. If the current session can't be resolved for some reason, it falls back to revoking all of them.
{ "message": "Sessions revoked" }There is no error response beyond the standard auth/internal ones — it always succeeds once you're authenticated at all.
curl
curl -X DELETE https://api.myapp.com/auth/sessions \
-H "Origin: https://myapp.com" \
-b cookies.txt -c cookies.txtProgrammatic (Go)
err := auth.Services.Session.RevokeAllExcept(ctx, userID, currentSessionID)
// or, if you don't have a "current" session to preserve:
err := auth.Services.Session.RevokeAll(ctx, userID)Client
await apiRequest(API_BASE, "DELETE", "/auth/sessions");For a complete "log out everywhere else" button, see Client → Sessions.
Next
- Authentication — register/login/logout, and the
Origin-checking rules these endpoints share - Routes — the full path/param reference, including admin session management
- Security — session hashing, refresh rotation, and the reuse-detection grace window
- Deployment for how these cookies behave when the frontend and the API are on different hosts
- Schemas — the
sessionstable this page reads and writes