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 the core package, one provider strategy, and the middleware package for your server. Use the advanced core path only if your app must control the routes, session storage, and callback handling.

Start here for Wisp or Mist: middleware handles the request and callback routes. First, make this base flow work. Then add more provider packages.

Vestibule packages are not available on Hex. Add them from GitHub with the moving v0 tag. Use Gleam 1.18 or later because companion packages use Git path dependencies. See the installation guide for paths and version information.

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

Route request and callback phases

Register the strategies once. Initialize the state store once. Then route the request and callback paths to the middleware.

What to notice: the request route stores temporary callback data and starts the redirect. The callback route returns an auth result only after state and PKCE validation.

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.create()
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. Reuse the store so that middleware can bind callback data to the user flow.

  • Let middleware consume callback data. After state validation, middleware removes the stored values before token exchange. A malformed callback or state mismatch does not remove a valid login session.

  • Map auth.uid(auth) to your own account. Vestibule authenticates the provider identity. Your app manages user accounts and 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 returns the authorization URL, state, and PKCE verifier. Your app stores the values and supplies them when it validates the callback.

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

Handle the two phases yourself

Before the redirect, store authorization_request.state(auth_request) and authorization_request.code_verifier(auth_request). Then redirect to authorization_request.url(auth_request). Supply the stored values during callback validation. Delete them after a successful callback.

What to notice: use an assertion only when you create the authorization request. A callback can be stale or forged. The provider can also reject it. Handle each error result.

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 the values to the user’s session. Set a short expiration time.

  • Do not reuse stored callback data. Delete the stored values after a successful callback. Expire unused values quickly. A state mismatch must not delete a valid login session.

  • Never assert the callback result. A stale tab, denied consent, a network error, or a forged state value can occur at run time.

When the callback fails

Many callback failures are harmless. Examples include a stale tab, use of the back button, or a reused link. A forged state value is hostile. Vestibule returns a typed error for each failure, so your app can respond without a crash.

What to notice: stop the current OAuth flow after a failure. Offer a retry only for a temporary upstream error. Never retry a state mismatch 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. For example, the user denied
// consent. Inspect error.provider_error(err) for the structured details.
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: The state is wrong or missing. This can indicate CSRF.
  • ProviderKind: The provider rejected the request. For example, the user denied consent.
  • MissingCallbackParamKind: The provider omitted a required value.
  • NetworkKind / CodeExchangeKind: A temporary upstream problem occurred.

How to respond

  • Discard the failed callback. Do not reuse its parameters.
  • Restart the flow after a stale tab, reused link, or state mismatch.
  • Offer a retry only for a temporary network or code-exchange failure.
  • Log the specific error on the server. Show users a generic sign-in error.

Do not expose the error. The reason can contain internal details. Log it on the server and return a generic message to the browser.

Before you demo

Vestibule has not been security audited — use it for demos and prototypes, not production. Even in a demo, complete these checks before you enable sign-in for users. Keep OAuth callback data on the server. Give the data a short expiration time and bind it to the user’s session. Reject missing or mismatched callbacks.

  • Non-local redirect URIs must use HTTPS.
  • Store state and PKCE verifier values on the server for a short time.
  • Remove client secrets, authorization codes, and PKCE verifiers from logs and error reports.
  • A cookie-secret change invalidates OAuth callbacks that are in progress.