The Getting Started tutorial served the brewery catalog to anyone — every request ran as the anonymous web_anon role and could only read. This tutorial adds the other half: letting an authenticated client do something the anonymous one cannot. Concretely, a brewery member will post a check-in — a write web_anon is not allowed to make.

Bier follows PostgREST's model exactly. There are no sessions, no login endpoint, and no user table Bier knows about. A client proves who it is by sending a JSON Web Token (JWT) signed with a secret the server also holds; Bier verifies the signature, reads the role claim, and runs that request's SQL under SET LOCAL ROLE <role>. Authorization is then plain PostgreSQL GRANTs — the database, not Bier, decides what each role may do.

This tutorial assumes you have already loaded brewery.sql and can run Bier against it, exactly as Getting Started describes.

The role split is already in the schema

brewery.sql created three roles on purpose (re-read its comments if you skipped them):

  • authenticator — the role Bier connects to Postgres as. It has noinherit login and no table privileges of its own; it can only switch into one of the roles below for the duration of a request.
  • web_anon — the anonymous role. It can select every table but cannot write.
  • brewery_member — additionally granted insert on api.check_ins (and usage on its id sequence).

The relevant grants:

grant select on api.styles, api.breweries, api.beers, api.taprooms, api.check_ins to web_anon;

grant select on api.styles, api.breweries, api.beers, api.taprooms, api.check_ins to brewery_member;
grant insert on api.check_ins to brewery_member;
grant usage on sequence api.check_ins_id_seq to brewery_member;

So the difference between "can post a check-in" and "cannot" is entirely a matter of which role the request runs as, and that is decided by the token.

Anonymous writes are denied

Boot Bier the same way as before, but connecting as the real authenticator role (not your superuser account — a superuser owns the tables and would bypass the very privilege checks this tutorial is about). Everything in this tutorial assumes Bier is reachable at http://localhost:4040.

iex -S mix
Bier.start_link(
  name: Tutorial,
  router: [port: 4040, scheme: :http],
  database: "bier_tutorial",
  username: "authenticator",
  password: "mysecretpassword",
  db_schemas: ["api"],
  db_anon_role: "web_anon"
)

(As in Getting Started, call start_link/1 at the iex prompt — an iex -S mix run -e '...' one-liner shuts the instance down again as soon as the expression returns.)

Now try to post a check-in with no token:

curl -i "http://localhost:4040/check_ins" \
  -H "Content-Type: application/json" \
  -d '{"beer_id":1,"drinker":"sam","rating":5,"comment":"great"}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: application/json; charset=utf-8
{"code":"42501","details":null,"hint":null,"message":"permission denied for table check_ins"}

The request ran as web_anon, which has no insert on api.check_ins, so PostgreSQL raised 42501 (insufficient privilege). Because the request was anonymous, Bier surfaces that as 401 Unauthorized with a WWW-Authenticate: Bearer header — the HTTP way of saying "authenticate and try again" — rather than a bare 403. That is exactly the behavior we want: the door is locked, and it tells the client a key would help.

Configure the JWT secret

To accept keys, Bier needs the secret it will verify signatures against. Add jwt_secret to the boot options. Bier verifies HS256 (HMAC) tokens when the secret is an ordinary string; the secret must be at least 32 bytes — a shorter one is rejected at boot.

Stop the running session (Ctrl+C twice), start a fresh iex -S mix, and call:

Bier.start_link(
  name: Tutorial,
  router: [port: 4040, scheme: :http],
  database: "bier_tutorial",
  username: "authenticator",
  password: "mysecretpassword",
  db_schemas: ["api"],
  db_anon_role: "web_anon",
  jwt_secret: "the-tutorial-jwt-secret-change-me-please"
)

Standalone, the same secret comes from an environment variable:

BIER_STANDALONE=1 \
PGRST_DB_URI="postgresql://authenticator:mysecretpassword@localhost:5432/bier_tutorial" \
PGRST_DB_SCHEMAS="api" \
PGRST_DB_ANON_ROLE="web_anon" \
PGRST_JWT_SECRET="the-tutorial-jwt-secret-change-me-please" \
_build/prod/rel/bier/bin/bier start

Role-switching was already happening — db_anon_role alone was enough to make every request run as web_anon. What the secret adds is the other half: Bier can now verify a token and switch into whatever role its role claim names, while a request without one still runs as db_anon_role. This is the same uniform model PostgREST uses — authentication is not opted in per-table or per-schema; it applies to the whole exposed surface.

Mint a token

A token for our purposes is a JWT with one claim, role, naming the PostgreSQL role the request should assume:

{"role": "brewery_member"}

It must be signed HS256 with the same secret Bier is configured with. Since :jose is already a dependency of this project, you can mint one in an iex session without any extra tooling:

secret = "the-tutorial-jwt-secret-change-me-please"
jwk = JOSE.JWK.from_oct(secret)

{_, token} =
  JOSE.JWT.sign(jwk, %{"alg" => "HS256"}, %{"role" => "brewery_member"})
  |> JOSE.JWS.compact()

IO.puts(token)

That prints a token like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYnJld2VyeV9tZW1iZXIifQ.UvAakyOakEKZK87bb3ljcEddeGulfjoJEDqN3zYxuSg

If you would rather not open a shell, jwt.io can build the same token in the browser: select the HS256 algorithm, set the payload to {"role": "brewery_member"}, and supply the secret as the signing key. The site hands back the encoded token to copy.

Where role comes from. Bier reads the role from the role claim by default. That path is the jwt_role_claim_key option (default $.role); if your identity provider nests the role elsewhere — say {"https://example.com/roles": ["brewery_member"]} — you point Bier at it with jwt_role_claim_key: "$[\"https://example.com/roles\"][0]". The value is an RFC 9535 JSON Path, so it always starts with the root identifier $; a value that does not parse aborts startup. See the Configuration guide for the supported subset.

Expiry. Real tokens should carry an exp (expiration) claim — a Unix timestamp after which Bier rejects the token with 401 (PGRST303, "JWT expired"). The minimal token above has none, so it never expires, which is fine for a tutorial and wrong for anything else.

Authenticated writes succeed

Send the same POST again, this time with the token in an Authorization: Bearer header. Prefer: return=representation asks Bier to return the inserted row:

TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYnJld2VyeV9tZW1iZXIifQ.UvAakyOakEKZK87bb3ljcEddeGulfjoJEDqN3zYxuSg"

curl -i "http://localhost:4040/check_ins" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"beer_id":1,"drinker":"sam","rating":5,"comment":"great"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
[{"id":6,"beer_id":1,"drinker":"sam","rating":5,"comment":"great","created_at":"2026-07-14T09:00:31.050203+00:00"}]

The check-in was created. Nothing about the SQL changed — the only difference from the denied request is the token, which made Bier run the insert as brewery_member instead of web_anon. Drop the header and the same request is still 401, exactly as before.

Reading the token's claims from SQL

Bier does more than switch the role: before running your query it also publishes the token's full claims to the transaction, where SQL can read them with current_setting('request.jwt.claims', true)::json. A tiny function makes this visible over HTTP — add it to your database:

create function api.whoami() returns json
  language sql stable as $$
    select current_setting('request.jwt.claims', true)::json;
  $$;

grant execute on function api.whoami() to web_anon, brewery_member;

Then reload Bier's schema cache so it picks up the new function (notify pgrst, 'reload schema'; from psql, or restart the instance) and call it. With the member token:

curl "http://localhost:4040/rpc/whoami" -H "Authorization: Bearer $TOKEN"
{"role": "brewery_member"}

And with no token, the anonymous claims Bier synthesizes for web_anon:

curl "http://localhost:4040/rpc/whoami"
{"role": "web_anon"}

This is the hook that makes row-level security practical: a policy or function can read any claim the token carried — a tenant id, a user id, a list of scopes — straight from request.jwt.claims and decide what the current request may see or do.

Optional: a pre-request guard

Sometimes role-based grants are not enough — you want a check that runs on every request, before the main query, and can reject it outright. That is the db_pre_request hook (PostgREST calls this the check_token pattern). Bier runs the named function inside the same transaction, right after establishing the role and claims; if it raises, the whole request is aborted.

For example, to ban a specific drinker regardless of their token:

create function api.check_token() returns void
  language plpgsql stable as $$
  declare
    claims json := current_setting('request.jwt.claims', true)::json;
  begin
    if claims ->> 'drinker' = 'banned_bob' then
      raise insufficient_privilege
        using message = 'account suspended';
    end if;
  end;
  $$;

Wire it in with the db_pre_request option (or PGRST_DB_PRE_REQUEST standalone):

Bier.start_link(
  # ...the options from above...
  jwt_secret: "the-tutorial-jwt-secret-change-me-please",
  db_pre_request: "api.check_token"
)

Now any request whose token carries "drinker": "banned_bob" is rejected before it can read or write anything, while every other request proceeds normally. Because the function runs inside the request transaction with the claims already set, it has the full token to reason about.

Notes and next steps

  • Rotate and protect the secret. Anyone who holds the JWT secret can mint a token for any role — treat it like a database password. The 32-byte minimum is a floor, not a recommendation; use a long random secret in production.
  • Set an exp. Tokens without an expiry are valid forever. Give real tokens a short lifetime and re-issue them.
  • Audience. If you set the jwt_aud option, Bier additionally requires the token's aud claim to match, rejecting tokens minted for a different service. It is unset (unchecked) by default.
  • Every JWT-related knob — jwt_secret, jwt_aud, jwt_role_claim_key, jwt_secret_is_base64, and the asymmetric (RS/ES/EdDSA) verification Bier also supports — is documented in the Configuration guide.

You now have the whole model: anonymous requests read the catalog as web_anon, and a signed token lets a client act as brewery_member and post check-ins — with PostgreSQL's own grants, not application code, drawing the line between them. From here the Configuration guide covers every option, and the API reference covers the full query and mutation grammar you can now use as either role.