Skip to content

Sign in with Costia

Costia is a standard OpenID Connect provider. If your library reads a discovery document, you do not have to copy a single URL:

https://id.costia.app/.well-known/openid-configuration

This page uses Better Auth because it is what Ambrosia, the example shop, uses, and the code you will see is what runs in production. With another library the shape changes, not the substance.

lib/auth/server.js
import { genericOAuth } from 'better-auth/plugins';
genericOAuth({
config: [
{
providerId: 'costia',
clientId: process.env.COSTIA_CLIENT_ID,
clientSecret: process.env.COSTIA_CLIENT_SECRET,
discoveryUrl: 'https://id.costia.app/.well-known/openid-configuration',
scopes: ['openid', 'profile', 'email', 'invoices:write'],
pkce: true,
authentication: 'basic',
},
],
});

These are the only two settings this plugin ships with in a form that cannot work against Costia. They fail at different moments and neither one says what happened.

The plugin ships false. Costia registers every client requiring PKCE, so a request without a code_challenge is rejected during validation: before the login screen and before consent. Your user comes back to your site with an error in the query string that the plugin does not surface.

What you see is that pressing the button does nothing. No login, no consent, no message. Every symptom is the absence of something.

authentication: 'basic' (confidential clients)

Section titled “authentication: 'basic' (confidential clients)”

The plugin ships 'post', which sends the credentials in the body. Your client is registered with client_secret_basic, and Spring checks the method the client was registered with, not whichever ones the server supports.

This one fails later: the person completes login and consent, and the token exchange — which happens server to server, where you cannot see it — returns invalid_client. It looks like a new problem unrelated to the previous one.

This setting applies to Better Auth running on your server. A native app is registered as a public client, carries no secret, and sends its client_id and code_verifier when exchanging the code.

await authClient.signIn.oauth2({
providerId: 'costia',
callbackURL: '/',
});

Better Auth receives the response at https://your-domain/api/auth/callback/costia.

An id_token with:

Claim When What it is
sub always The person’s identifier in Costia. Stable, and not their email
name with profile Their full name
email with email Their email
email_verified with email true; an unverified email is not published

Google sign-in verifies the address asserted by Google. Apple verifies it only when Apple shares that address. Creating an email/password account, or manually adding an email after Apple sign-in, does not verify it. Costia remains usable, but the email scope stays blocked until a provider verifies it.

The consent screen has nothing pre-ticked except identification. It is a GDPR requirement — Recital 32 names pre-ticked boxes as something that does not constitute consent — and it means your customer can sign in without granting you their email, or without letting you write invoices.

When they uncheck something they are told explicitly what your application will no longer be able to do, so it is an informed decision and not an oversight. Your code has to cope: a missing email is not an error, and a 403 when writing an invoice means they declined, not that something is broken.