---
title: Sign in with sherlock
description: A complete, runnable Express app doing the OAuth 2.1 authorization-code flow end to end — login, callback, id_token, the Data API, and refresh.
---

# Sign in with sherlock

[Sign in with sherlock (redirect)](/sherlock/sign-in-with-sherlock-by-redirect)
walks through each step of the flow on its own. This page is the
complete, runnable version — a small Express app with a `/login`,
`/callback`, and `/refresh` route, wired against the real
`auth.shebang.pro` endpoints. Register your own client first (either
path that concept page describes), set `CLIENT_ID` and a publishable key
for your database's Data API, and this runs as-is.

```js
import express from "express";
import crypto from "node:crypto";
import { createClient } from "@supabase/supabase-js";

const ISSUER = "https://auth.shebang.pro/auth/v1";
const AUTHORIZE_ENDPOINT = `${ISSUER}/oauth/authorize`;
const TOKEN_ENDPOINT = `${ISSUER}/oauth/token`;
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const REDIRECT_URI = "https://myapp.example.com/callback";
const DATA_API_URL = "https://api.shebang.pro/db/<your-slug>";
const DATA_API_PUBLISHABLE_KEY = process.env.SHERBASE_PUBLISHABLE_KEY;

const app = express();
const pendingLogins = new Map(); // state -> { verifier }
let session = null;

function b64url(buf) {
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function decodeJwtPayload(jwt) {
  const [, payload] = jwt.split(".");
  return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
}

app.get("/login", (req, res) => {
  const verifier = b64url(crypto.randomBytes(32));
  const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
  const state = b64url(crypto.randomBytes(16));
  pendingLogins.set(state, { verifier });

  const url = new URL(AUTHORIZE_ENDPOINT);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("client_id", CLIENT_ID);
  url.searchParams.set("redirect_uri", REDIRECT_URI);
  url.searchParams.set("scope", "openid email profile");
  url.searchParams.set("code_challenge", challenge);
  url.searchParams.set("code_challenge_method", "S256");
  url.searchParams.set("state", state);

  res.redirect(url.toString());
});

app.get("/callback", async (req, res) => {
  const { code, state, error } = req.query;
  if (error) return res.status(400).send(`authorize error: ${error}`);

  const entry = pendingLogins.get(state);
  if (!entry) return res.status(400).send("unknown or expired state");
  pendingLogins.delete(state);

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code: String(code),
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: entry.verifier,
  });

  const tokenResp = await fetch(TOKEN_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });
  const tokenJson = await tokenResp.json();
  if (!tokenResp.ok) return res.status(502).send("token exchange failed");

  const claims = decodeJwtPayload(tokenJson.id_token);
  session = {
    access_token: tokenJson.access_token,
    refresh_token: tokenJson.refresh_token,
    expires_at: Date.now() + (tokenJson.expires_in || 3600) * 1000,
    claims,
  };

  // Call the Data API as this signed-in user -- row-level security sees
  // them as auth.uid(), same as any other supabase-js session.
  const supabase = createClient(DATA_API_URL, DATA_API_PUBLISHABLE_KEY, {
    global: { headers: { Authorization: `Bearer ${session.access_token}` } },
  });
  const { data: notes } = await supabase.from("notes").select("*").order("id");

  res.send(`signed in as ${claims.email} (${notes?.length ?? 0} notes)`);
});

app.get("/refresh", async (req, res) => {
  if (!session) return res.status(400).send("not signed in");

  const body = new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: session.refresh_token,
    client_id: CLIENT_ID,
  });
  const tokenResp = await fetch(TOKEN_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });
  const tokenJson = await tokenResp.json();
  if (!tokenResp.ok) return res.status(502).send("refresh failed");

  session.access_token = tokenJson.access_token;
  session.refresh_token = tokenJson.refresh_token || session.refresh_token;
  session.expires_at = Date.now() + (tokenJson.expires_in || 3600) * 1000;
  res.send("refreshed");
});

app.listen(3000);
```

What to notice:

- **`/login`** builds the PKCE challenge and `state`, then redirects to
  sherlock's own authorize endpoint — your app never renders a password
  form.
- **`/callback`** is the only route that talks to `TOKEN_ENDPOINT`
  directly; everything after the exchange (decoding `id_token`, calling
  the Data API) runs with the tokens already in hand.
- **`/refresh`** is a plain `grant_type=refresh_token` call — no PKCE
  needed the second time, since the refresh token itself is the proof.
- This example keeps its session in memory for clarity. A real app
  needs to persist it somewhere it can survive a restart — cookies,
  a session store, whatever your app already uses for sessions — that
  part is on you; it isn't specific to sherlock.

## Next

[The Data API](/sherbase/the-data-api) — what `auth.uid()` and
row-level security look like from the database side of this same token.
