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-configurationThis 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.
The configuration
Section titled “The configuration”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', }, ],});The two settings you have to change
Section titled “The two settings you have to change”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.
pkce: true
Section titled “pkce: true”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.
The button
Section titled “The button”await authClient.signIn.oauth2({ providerId: 'costia', callbackURL: '/',});Better Auth receives the response at
https://your-domain/api/auth/callback/costia.
What you get back
Section titled “What you get back”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.
If the person unchecks something
Section titled “If the person unchecks something”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.
