Skip to main content
Shipyard authentication is browser-driven. Your frontend redirects the user to GET /api/auth/github, which sends the browser to GitHub’s authorization page. After the user approves access, GitHub redirects back to GET /api/auth/github/callback with a one-time code. The server exchanges that code for a GitHub access token, upserts a user record, signs a JWT, and redirects the browser to your frontend with the token in the query string. Neither endpoint requires an existing JWT — they are the mechanism that produces one.

GET /api/auth/github

Redirects the browser to GitHub’s OAuth authorization page. No request body or authentication is needed. No auth required. When the user visits this URL, the server responds with 302 and sets the Location header to:
https://github.com/login/oauth/authorize?client_id=<CLIENT_ID>&scope=read:user,repo,read:org&prompt=consent
The prompt=consent parameter forces the GitHub authorization screen to appear even if the user previously authorized the app, so they always see which scopes are being requested.

Requested scopes

ScopePurpose
read:userRead the user’s GitHub profile (login, avatar, email)
repoClone private repositories and register webhooks
read:orgList organizations the user belongs to

Curl example

Visiting this URL in a browser is the intended use. To inspect the redirect target without following it:
curl -I https://your-server/api/auth/github
HTTP/1.1 302 Found
Location: https://github.com/login/oauth/authorize?client_id=Iv1.abc123&scope=read:user,repo,read:org&prompt=consent

GET /api/auth/github/callback

Handles the OAuth callback from GitHub. GitHub appends a one-time code to this URL after the user authorizes the app. The server exchanges the code for an access token, fetches the user’s GitHub profile, upserts a user record in the database, signs a JWT, and redirects the browser to FRONTEND_URL/auth/callback with the session data in the query string. No auth required.

Query parameters

code
string
required
The one-time authorization code provided by GitHub after the user grants access. GitHub appends this to your callback URL automatically — you do not construct or send it yourself.

Success redirect

On success, the server responds with 302 and redirects to:
<FRONTEND_URL>/auth/callback?token=<jwt>&username=<login>&avatar=<avatar_url>&email=<email>&createdAt=<timestamp>
The query parameters carry everything your frontend needs to initialize a session:
ParameterDescription
tokenSigned JWT, valid for 1 hour. Pass this as Authorization: Bearer <token> on all subsequent API requests.
usernameThe user’s GitHub login
avatarURL of the user’s GitHub avatar
emailPrimary verified email address. Shipyard falls back to /user/emails if the profile email is hidden.
createdAtTimestamp of when the user record was first created in Shipyard

Error responses

StatusCondition
400The code query parameter is missing from the request
500GitHub did not return an access token, or the user profile could not be fetched

Handling the callback in your frontend

When the browser lands on your frontend’s /auth/callback route, parse the query parameters and store the token for subsequent API calls:
// On your callback page at /auth/callback
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
const username = params.get("username");
const avatar = params.get("avatar");
const email = params.get("email");
const createdAt = params.get("createdAt");

// Store the token for subsequent API calls
localStorage.setItem("shipyard_token", token);
Then attach the token to every authenticated request:
const response = await fetch("/api/project/projects", {
  headers: {
    Authorization: `Bearer ${localStorage.getItem("shipyard_token")}`,
  },
});
The JWT expires after 1 hour. When it expires, the user must go through the OAuth flow again — redirect them to /api/auth/github to re-authenticate.

Build docs developers (and LLMs) love