Docs menu: Custom strategy guide

Writing a custom strategy

Build a Vestibule OAuth2 provider strategy from scratch.

What the core handles

Vestibule generates and validates CSRF state, manages PKCE, resolves configured scopes, appends PKCE parameters, and dispatches token refresh through the strategy.

  • Your strategy builds provider URLs.
  • Your strategy exchanges codes for credentials.
  • Your strategy fetches and normalizes user info.

The Strategy type

The provider name, default scopes, and four functions define the full contract. The custom error type flows through AuthError(e).

pub type Strategy(e) {
  Strategy(
    provider: String,
    default_scopes: List(String),
    authorize_url: fn(Config, List(String), String) ->
      Result(String, AuthError(e)),
    exchange_code: fn(Config, String, Option(String)) ->
      Result(ExchangeResult, AuthError(e)),
    refresh_token: fn(Config, String) -> Result(Credentials, AuthError(e)),
    fetch_user: fn(Config, ExchangeResult) -> Result(UserResult, AuthError(e)),
  )
}
1

Create the provider package

A provider can live in its own package. This example starts a Twitch strategy package that depends on Vestibule and the same OAuth helpers used by the built-in strategies.

name = "vestibule_twitch"
version = "0.1.0"
description = "Twitch OAuth strategy for Vestibule"
licences = ["MIT"]
gleam = ">= 1.7.0"

[dependencies]
vestibule = ">= 1.0.0 and < 2.0.0"
gleam_stdlib = ">= 0.48.0 and < 2.0.0"
gleam_http = ">= 4.3.0 and < 5.0.0"
gleam_httpc = ">= 5.0.0 and < 6.0.0"
gleam_json = ">= 3.1.0 and < 4.0.0"
glow_auth = ">= 1.0.1 and < 2.0.0"
2

Build the authorization URL

The function receives the already-resolved scopes and state value. Return the provider URL; Vestibule appends the PKCE challenge.

fn do_authorize_url(
  cfg: Config,
  scopes: List(String),
  state: String,
) -> Result(String, AuthError(e)) {
  let assert Ok(site) = uri.parse("https://id.twitch.tv")
  use redirect <- result.try(
    provider_support.parse_redirect_uri(config.redirect_uri(cfg)),
  )

  let client =
    glow_auth.Client(
      id: config.client_id(cfg),
      secret: config.client_secret(cfg),
      site: site,
    )

  let url =
    authorize_uri.build(
      client,
      uri_builder.RelativePath("/oauth2/authorize"),
      redirect,
    )
    |> authorize_uri.set_scope(string.join(scopes, " "))
    |> authorize_uri.set_state(state)
    |> authorize_uri.to_code_authorization_uri()
    |> uri.to_string()

  Ok(url)
}
3

Exchange the code and fetch the user

Exchange the authorization code with the provider token endpoint, parse the response into ExchangeResult, then use the credentials and artifacts to fetch a stable provider UID and normalized UserInfo.

fn do_exchange_code(
  cfg: Config,
  code: String,
  code_verifier: Option(String),
) -> Result(strategy.ExchangeResult, AuthError(e)) {
  // Build and send the provider token request, then parse credentials.
  use body <- result.try(post_token_request(cfg, code, code_verifier))
  provider_support.parse_oauth_token_response(
    body,
    provider_support.OptionalScope(" "),
  )
  |> result.map(strategy.exchange_result)
}

fn do_fetch_user(
  _cfg: Config,
  exchange: strategy.ExchangeResult,
) -> Result(strategy.UserResult, AuthError(e)) {
  use auth_header <- result.try(
    strategy.authorization_header(strategy.exchange_credentials(exchange)),
  )
  use #(uid, info) <- result.try(provider_support.fetch_json_with_auth(
    "https://id.twitch.tv/oauth2/userinfo",
    auth_header,
    parse_user_response,
    "Twitch userinfo",
  ))

  Ok(strategy.user_result(uid: uid, info: info, extra: dict.new()))
}
4

Expose the final strategy value

Export a strategy() function that returns Strategy(e) when the provider only needs built-in error variants, or Strategy(YourError) when it exposes custom domain-specific errors.

pub fn strategy() -> Strategy(e) {
  strategy.new(
    provider: "twitch",
    default_scopes: ["user:read:email"],
    authorize_url: do_authorize_url,
    exchange_code: do_exchange_code,
    refresh_token: do_refresh_token,
    fetch_user: do_fetch_user,
  )
}

Authoring checklist

  • Use provider_support helpers before copying built-in internals.
  • Keep scopes minimal and provider-owned.
  • Normalize user info without pretending optional provider fields always exist.
  • Make refresh-token behavior explicit; providers differ on rotation and returned scopes.
  • Add tests for URL building, token parsing, profile normalization, and failure responses.