go-auth
Guides

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
})
FieldTypeRequiredDefaultNotes
TTLtime.DurationOptional30dAbsolute hard expiry — the expiresAt on every session in the list responses above.
IdleTTLtime.DurationOptional7dTimeout since last activity. Must not exceed TTL.
RefreshTokenTTLtime.DurationOptional30dMust not be less than TTL. Governs how long the transparent refresh described in the curl note above keeps working after the session itself expires.
TokenTTLtime.DurationOptional1hLifetime of verification and password-reset tokens.
MaxLifetimetime.DurationOptional0 — no limitIf set, must be >= TTL.
GraceWindow*time.DurationOptional5sWindow 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.DurationOptional5mMinimum 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 positive
  • session_idle_ttl must be positive
  • session_idle_ttl must not exceed session_ttl
  • refresh_token_ttl must be positive
  • refresh_token_ttl must not be less than session_ttl
  • session token_ttl must be positive — only reachable via an explicit negative value; an omitted one defaults to 1h.
  • 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 when MaxLifetime is 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
})
FieldTypeRequiredDefaultNotes
NamestringOptionalgoauth_sessionThe cookie name every curl example on this page passes via -b/-c. Change it here and the examples change with it.
RefreshNamestringOptionalgoauth_refreshThe cookie AuthMiddleware reads for the transparent refresh.
DomainstringOptional"" — host-only cookie
PathstringOptional/
SameSitehttp.SameSiteOptionalLax
Secure*boolOptionalderived — 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 configured CookieConfig/SessionConfig settings. Pass "" for refreshToken to 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 after a.Services.Session.Revoke succeeds. 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 into SetSessionCookies, 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 if SecurityConfig.DisableCSRFToken is 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.txt

AuthMiddleware 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 orgactiveOrgId/activeOrgRole — which is otherwise not exposed anywhere: PUT/DELETE /auth/orgs/active only return a bare {"message"}.

Which fields you actually get:

FieldWhen it appears
id, userId, isRevoked, expiresAt, createdAtAlways. isRevoked is always false here — a revoked session never gets past AuthMiddleware to reach this handler.
ipAddress, userAgent, parsedUAWhen they were recorded at login. parsedUA is derived from userAgent, so the two travel together.
refreshExpiresAtWhenever a refresh cookie was issued — in practice, always for a browser login.
refreshRotatedAtOnly after the refresh token has rotated at least once.
lastActiveAtOnce the session has been touched (subject to TouchDebounce).
activeOrgId, activeOrgRoleOnly while an org is active. Both absent otherwise — not null.
revokedAtNever 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.

CodeStatusCause
session_expired401No session cookie, or one that expired with no usable refresh cookie
unauthorized401Session cookie doesn't validate (bad signature, not found, user deleted)
user_banned403Account 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.txt

Programmatic (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"
  }
}
CodeStatusCause
invalid_refresh401No 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.txt

Programmatic (Go)

result, err := auth.Services.Session.Refresh(ctx, refreshToken)
// result.SessionToken / result.RefreshToken are the raw values — set them as cookies yourself

Client

await apiRequest(API_BASE, "POST", "/auth/refresh");
// new cookies are set automatically by the browser

For 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

ParamTypeDefaultNotes
offsetint0
limitint20Clamped 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..."
}
CodeStatusCause
session_expired / unauthorized401Same as Checking who's logged in above
internal_error500Database failure

curl

curl "https://api.myapp.com/auth/sessions?offset=0&limit=20" -b cookies.txt -c cookies.txt

Programmatic (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.txt

Programmatic (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" }
CodeStatusCause
session_not_found404ID doesn't exist, or exists but isn't yours — the two cases are indistinguishable on purpose
internal_error500Database failure

curl

curl -X DELETE https://api.myapp.com/auth/sessions/9e2c... \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt

Programmatic (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.

CodeStatusCause
invalid_input400sessionIds empty, or more than 100 entries
invalid_json400Malformed body
internal_error500Database 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.txt

Programmatic (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 sessions table this page reads and writes

On this page