Docs menu: Quick start

Quick start

Add Vestibule to a Gleam app and wire the OAuth request and callback phases.

Recommended for Wisp and Mist apps

Let middleware own the auth routes

If your app uses Wisp or Mist, start here. Add core, a provider strategy, and the middleware package for your server layer. Use the advanced core path only when your app needs to own routing, session storage, and callback handling directly.

Start here for Wisp or Mist: middleware owns the request and callback routes. Add provider packages after this base flow works.

Vestibule is not yet 1.0, so install it from GitHub using the moving vestibule-v0.0 tag. Gleam 1.18 or later is required because companion packages use git path dependencies.

[dependencies]
vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "vestibule-v0.0" }
vestibule_github = { git = "https://github.com/tylerbutler/vestibule.git", ref = "vestibule-v0.0", path = "packages/vestibule_github" }
vestibule_wisp = { git = "https://github.com/tylerbutler/vestibule.git", ref = "vestibule-v0.0", path = "packages/vestibule_wisp" }

Route request and callback phases

Register strategies once, initialize the state store once, then pass request and callback routes to the middleware.

What to notice: the request route starts the redirect and stores transient callback data; the callback route returns an auth result only after state and PKCE checks pass.

import gleam/http
import wisp
import vestibule/config
import vestibule/registry
import vestibule/state_store
import vestibule_wisp
import vestibule_github

let assert Ok(registry) =
  registry.new()
  |> registry.register(
    vestibule_github.strategy(),
    config.new(
      client_id: "client_id",
      redirect_uri: "http://localhost:8000/auth/github/callback",
      auth: config.ClientSecret("client_secret"),
    ),
  )

let assert Ok(store) = state_store.try_init()

case wisp.path_segments(req), req.method {
  ["auth", provider], http.Get ->
    vestibule_wisp.request_phase(
      req,
      registry,
      provider,
      store,
      authorize_options: config.authorize_options(),
    )

  ["auth", provider, "callback"], http.Get
  | ["auth", provider, "callback"], http.Post ->
    case vestibule_wisp.callback_phase_auth_result(req, registry, provider, store) {
      // auth.uid(auth) identifies the user: map it to an account, then
      // start your own session.
      Ok(auth) -> start_session(auth)

      // Benign: a stale tab, back button, or already-used callback.
      Error(vestibule_wisp.SessionUnavailable) ->
        wisp.redirect("/login?error=expired")

      // Everything else (forged state, provider rejection, bad params).
      Error(_) ->
        wisp.redirect("/login?error=auth")
    }

  _, _ ->
    wisp.not_found()
}
  • Initialize the state store once. Reusing it lets middleware bind callback data to the user flow.

  • Delete callback data after success or failure. The middleware consumes one-time state; your app should not reuse a failed callback.

  • Map auth.uid(auth) to your own account. Vestibule authenticates the provider identity; your app owns sessions.

Using Mist? The route shape is the same; use vestibule_mist for plain Mist handlers.

Advanced path for custom routingUse core when your app owns the flowChoose this when you handle routing, sessions, and callback storage yourself.

Core gives you the authorization URL, state, and PKCE verifier, then validates the callback after your app supplies the stored values.

[dependencies]
vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "vestibule-v0.0" }
vestibule_github = { git = "https://github.com/tylerbutler/vestibule.git", ref = "vestibule-v0.0", path = "packages/vestibule_github" }

Handle the two phases yourself

Store authorization_request.state(auth_request) and authorization_request.code_verifier(auth_request) before redirecting to authorization_request.url(auth_request). Pass the stored values back during callback validation and delete them after success.

What to notice: asserting is safe only while creating the authorization request. The callback can be stale, forged, or rejected by the provider, so handle every error branch.

import gleam/dict
import gleam/option
import vestibule
import vestibule/authorization_request
import vestibule/config
import vestibule/error
import vestibule_github

let strategy = vestibule_github.strategy()
let client_config =
  config.new(
    client_id: "client_id",
    redirect_uri: "http://localhost:8000/auth/github/callback",
    auth: config.ClientSecret("client_secret"),
  )

let options = config.authorize_options()
let assert Ok(auth_request) =
  vestibule.create_authorization_request(
    strategy,
    config: client_config,
    options: options,
  )
// Store authorization_request.state(auth_request) and
// authorization_request.code_verifier(auth_request) server-side,
// bound to this user's session, with an expiration time.
// Redirect user to authorization_request.url(auth_request).

let params =
  dict.from_list([
    #("state", "state from callback"),
    #("code", "authorization code from callback"),
  ])

// Validate the callback. Never assert here: state can mismatch and
// providers can reject the user.
case
  vestibule.handle_callback(
    strategy,
    client_config,
    params,
    "expected state from session",
    "code verifier from session",
    expected_nonce: option.None,
  )
{
  Ok(auth) -> {
    // Delete the stored state and code_verifier, then map auth.uid(auth)
    // to an account and start a session.
    sign_in(auth)
  }
  // Possible CSRF or a stale tab: discard and restart the flow.
  Error(err) ->
    case error.kind(err) {
      error.StateMismatchKind -> restart_sign_in()
      // Provider, code-exchange, network, or decode failure.
      _ -> show_auth_error(err)
    }
}
  • Store state and PKCE verifier server-side before redirecting. Bind them to the user’s session and expire them quickly.

  • Delete stored callback data after every result. Success, provider rejection, and state mismatch should all consume the stored values.

  • Never assert the callback result. Stale tabs, denied consent, network errors, and forged state are normal runtime cases.

When the callback fails

Most callback failures are benign — a stale tab, the back button, or a re-used link. Some are hostile, like a forged state. Vestibule returns a typed error for both so you can respond safely instead of crashing the request.

What to notice: every failure restarts or exits the OAuth flow. Only transient upstream errors should offer a retry; a state mismatch should never be retried with the same callback data.

import gleam/option
import vestibule
import vestibule/error

case
  vestibule.handle_callback(
    strategy,
    client_config,
    params,
    expected_state,
    verifier,
    expected_nonce: option.None,
  )
{
  Ok(auth) -> sign_in(auth)

  // Classify the failure with error.kind/0; the ErrorKind enum carries an
  // OtherKind catch-all, so new kinds never break this match.
  Error(err) ->
    case error.kind(err) {
      // Wrong state: treat as hostile. Log server-side, restart the flow.
      error.StateMismatchKind -> restart_sign_in()

      // The provider rejected the request (e.g. denied consent). Inspect
      // error.provider_error(err) for the structured code/description.
      error.ProviderKind -> back_to_login()

      // Transient upstream failures are worth a retry prompt.
      error.NetworkKind | error.CodeExchangeKind -> offer_retry()

      // Catch-all: show a generic message, keep error.message(err) in logs.
      _ -> show_auth_error(err)
    }
}

Why a callback fails

  • StateMismatchKind — wrong or missing state; possible CSRF.
  • ProviderKind — the provider rejected the request (e.g. denied consent).
  • MissingCallbackParamKind — the provider omitted a required value.
  • NetworkKind / CodeExchangeKind — a transient upstream problem.

How to respond

  • Discard the stored state and verifier on every failure.
  • Restart the flow for stale tabs, reused links, and state mismatch.
  • Offer retry only for transient network or code-exchange failures.
  • Log the specific error server-side; show users a generic sign-in failure.

Do not leak the error. The reason can name internal details; log it server-side and return a generic message to the browser.

Before shipping

Check these before enabling sign-in for real users. Keep OAuth callback data server-side, short-lived, and bound to the user’s session. Reject missing or mismatched callbacks.

  • Production redirect URIs must use HTTPS.
  • State and PKCE verifier values must be short-lived and server-side.
  • Bearer tokens must be redacted from logs and error reports.
  • Cookie-secret rotation invalidates in-flight OAuth callbacks.