Quick start
Add Vestibule to a Gleam app and wire the OAuth request and callback phases.
Your selected path
Wisp + GitHub
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[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" }Your selected path
Wisp + Google
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_google = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_google" }vestibule_wisp = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_wisp" }Your selected path
Wisp + Microsoft
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_microsoft = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_microsoft" }vestibule_wisp = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_wisp" }Your selected path
Wisp + Apple
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_apple = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_apple" }vestibule_wisp = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_wisp" }Your selected path
Mist + GitHub
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[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_mist = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_mist" }Your selected path
Mist + Google
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_google = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_google" }vestibule_mist = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_mist" }Your selected path
Mist + Microsoft
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_microsoft = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_microsoft" }vestibule_mist = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_mist" }Your selected path
Mist + Apple
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_apple = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_apple" }vestibule_mist = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_mist" }Your selected path
custom router + GitHub
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[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" }Your selected path
custom router + Google
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_google = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_google" }Your selected path
custom router + Microsoft
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_microsoft = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_microsoft" }Your selected path
custom router + Apple
Start with this dependency block, then use the matching route and provider guides. Your selection stays in the URL so you can return to it or change it.
[dependencies]vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }vestibule_apple = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0", path = "packages/vestibule_apple" }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/httpimport wispimport vestibule/configimport vestibule/registryimport vestibule/state_storeimport vestibule_wispimport 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/dictimport gleam/optionimport vestibuleimport vestibule/authorization_requestimport vestibule/configimport vestibule/errorimport 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/optionimport vestibuleimport 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.