Quick start
Add Vestibule to a Gleam app and wire the OAuth request and callback phases.
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.