Wire up a new bot
Four steps. Only the first needs Discord's web portal, and it is the same paste every time.
1. Register the redirect on your Discord application
Discord Developer Portal → your application → OAuth2 → Redirects → Add Redirect, then Save. This exact string, with no trailing slash:
https://auth.lunarwerx.com/callback
This is the only manual step, and it cannot be automated: Discord's API accepts a PATCH
to redirect_uris and silently ignores it. Verified the hard way.
2. Register the application here
Once. The client secret is encrypted at rest and is never returned by any endpoint, at any permission level.
curl -X POST https://auth.lunarwerx.com/v1/apps \
-H "Authorization: Bearer $LUNARWERX_AUTH_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": "1234567890123456789",
"slug": "your-bot",
"name": "Your Bot",
"client_secret": "…",
"scopes": ["identify", "guilds"],
"origins": []
}'
origins is for hosts outside lunarwerx.com. Anything under
lunarwerx.com is trusted automatically, so a bot living at
your-bot.lunarwerx.com needs nothing here. A white-label customer serving
from their own domain gets their exact origin added to this list, and nothing else will
be handed a session.
3. Send people here to sign in
Generate a PKCE verifier, keep it in a short-lived cookie, and redirect. The
origin you pass is where the finished login is handed back.
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(await crypto.subtle.digest('SHA-256', encode(verifier)));
const url = new URL('https://auth.lunarwerx.com/authorize');
url.searchParams.set('app', 'your-bot');
url.searchParams.set('origin', 'https://your-bot.lunarwerx.com');
url.searchParams.set('return', '/api/auth/return'); // default
url.searchParams.set('next', '/dashboard'); // where to land afterwards
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
// Store `verifier` in an HttpOnly cookie, then 302 to url.
4. Redeem the code, verify the token
// GET /api/auth/return?code=…&next=…
const res = await fetch('https://auth.lunarwerx.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: verifierFromCookie, client_id: CLIENT_ID }),
});
const { id_token, user, discord } = await res.json();
// Verify against the published key set before trusting a single claim.
const claims = await verifyIdentityToken(id_token, { issuer: 'https://auth.lunarwerx.com', audience: CLIENT_ID });
// claims.sub is the Discord user id. discord.access_token is theirs to use.
The drop-in client at services/auth/client/lunarwerx-auth.ts does all of the
above, including the key-set fetch and cache. Copy the file; it has no dependencies.
What you get back
{
"id_token": "eyJhbGciOiJFUzI1NiIs…", ES256, verify against /.well-known/jwks.json
"expires_in": 604800,
"user": { "id", "name", "username", "avatarUrl" },
"discord": { "access_token", "refresh_token", "expires_in" },
"scope": "identify guilds"
}
The Discord tokens are in the body, once, to your server. They are deliberately NOT in the identity token: whatever you do with the JWT, usually a cookie, would be where they ended up too.
When something is refused
Every refusal names a reason. The ones you will actually meet:
origin_not_allowed the origin is not under lunarwerx.com and is not on the app
unknown_application no app registered under that slug or client id
invalid_state the flow did not start here, or started in another browser
state_expired more than 15 minutes between starting and finishing
invalid_code already redeemed, or never existed. Codes are single-use
expired_code more than 2 minutes between callback and redemption
invalid_verifier the PKCE verifier does not match the challenge
client_mismatch that code was minted for a different application
Rotating keys
Rotation is additive. The old key keeps appearing in the key set until every token it signed has expired, so nobody is logged out.
curl -X POST https://auth.lunarwerx.com/v1/keys -H "Authorization: Bearer $LUNARWERX_AUTH_ADMIN_TOKEN"